From cc8ab42047ac4f2de94cf5fbbafbc6a8307d68df Mon Sep 17 00:00:00 2001 From: Assem130 <104645338+Assem130@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:02:51 +0100 Subject: [PATCH 1/2] Add file search to root search A Files section after Results lists files and folders from the home directory that match the query. fd lists the home directory with fixed arguments when the launcher opens (about 55 ms for 25k entries); the query is matched in-process, so it never reaches an argv. - core/FileSearch.js: listing parser, ranking (exact name, prefix, word start, substring, path), recency tie-break, glyphs, breadcrumbs, dates - services/FileIndex.qml: fd + stat listing, default excludes for node_modules, virtualenvs, caches and build output - Actions: Open File (gio open, so terminal apps such as nvim get a terminal), Show in Files, Copy Path (Ctrl+Shift+C) - The selection follows the first file when the listing arrives after the first answer --- Backend.qml | 57 +++++++++++- core/FileSearch.js | 181 ++++++++++++++++++++++++++++++++++++++ core/RootSearch.js | 20 +++++ services/FileIndex.qml | 58 ++++++++++++ tests/file-search.test.js | 103 ++++++++++++++++++++++ ui/LauncherWindow.qml | 8 +- 6 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 core/FileSearch.js create mode 100644 services/FileIndex.qml create mode 100644 tests/file-search.test.js diff --git a/Backend.qml b/Backend.qml index 8895670..9a6f4b5 100644 --- a/Backend.qml +++ b/Backend.qml @@ -12,6 +12,7 @@ import "core/Frecency.js" as Frecency import "core/OmarchyMenu.js" as OmarchyMenu import "core/RootSearch.js" as RootSearch import "core/Calculator.js" as Calculator +import "core/FileSearch.js" as FileSearch import "services" Item { @@ -126,6 +127,46 @@ Item { return c ? RootSearch.decorate(c, backend.searchContext(), backend.deps) : null } + // ----------------------------------------------------------- file search + + // Rows from the last search, by id, so actions can find their path. + property var fileRows: ({}) + // Bumped when a new listing arrives, so an open search shows its files. + readonly property double filesRevision: fileIndex.refreshedAt + + function fileCandidates(query, nowMs) { + var rows = FileSearch.search(fileIndex.entries, query, nowMs, FileSearch.DEFAULT_LIMIT) + .map(function(e) { return FileSearch.candidate(e, nowMs, RootSearch.relativeTime) }) + var byId = ({}) + for (var i = 0; i < rows.length; i++) byId[rows[i].id] = rows[i] + backend.fileRows = byId + return rows + } + + function fileAction(action, row) { + switch (action) { + case "open": + // gio, not xdg-open: xdg-open runs a Terminal=true default (nvim) + // with no terminal, so it never shows. gio opens it in one, and it + // picks the same app as a double-click in Files. + Util.execArgv(["uwsm-app", "--", "gio", "open", row.path]) + return { close: true } + case "show-in-files": + Util.execArgv(["uwsm-app", "--", "nautilus", "--select", row.path]) + return { close: true } + case "copy-path": + clipboard.copy(row.path) + return { close: true, hud: "Copied to Clipboard" } + } + throw new Error("Unknown action " + action) + } + + FileIndex { + id: fileIndex + home: Quickshell.env("HOME") || "" + onMessage: function(level, text, metadata) { backend.log(level, "files", text, metadata) } + } + // ------------------------------------------------------------ calculator // 12- or 24-hour clock from config.json clockFormat, else the Qt locale. @@ -431,7 +472,10 @@ Item { else if (view && view.kind === "calculator-history") sections = RootSearch.historySections(calculatorStore.doc.history, query, ctx.nowMs, Calculator.filterHistory) else { ctx.filter = String(data && data.filter || "all") - if (query.trim() && ctx.filter === "all") ctx.calculation = backend.calculate(query) + if (query.trim() && ctx.filter === "all") { + ctx.calculation = backend.calculate(query) + ctx.files = backend.fileCandidates(query, ctx.nowMs) + } sections = RootSearch.rootSections(backend.candidates, query, ctx, backend.deps) } return sections.map(function(s) { @@ -442,6 +486,7 @@ Item { var rowId = String(data && data.id || "") if (rowId === RootSearch.CALCULATOR_ID) return RootSearch.actionsFor({ kind: "calculator" }) if (rowId.indexOf("calculator-history:") === 0) return RootSearch.actionsFor({ kind: "calculator-history" }) + if (backend.fileRows[rowId]) return RootSearch.actionsFor(backend.fileRows[rowId]) var candidate = backend.decorated(rowId) if (!candidate) { var fallback = RootSearch.FALLBACKS.filter(function(f) { return f.id === data.id })[0] @@ -461,6 +506,7 @@ Item { var entry = backend.historyEntry(rowId) return entry ? RootSearch.aboutFor(RootSearch.historyCandidate(entry, nowMs), nowMs) : [] } + if (backend.fileRows[rowId]) return RootSearch.aboutFor(backend.fileRows[rowId], nowMs) var candidate = backend.decorated(rowId) if (!candidate) { var f = RootSearch.FALLBACKS.filter(function(x) { return x.id === rowId })[0] @@ -491,11 +537,19 @@ Item { if (!entry) throw new Error("No such history entry") return backend.calculatorAction(String(data.action || "open"), entry, entry.id) } + var fileRow = data && backend.fileRows[String(data.id)] + if (fileRow) return backend.fileAction(String(data.action || "open"), fileRow) return backend.performAction(data) }, getSearchHistory: function() { return frecencyStore.loaded ? frecencyStore.doc.searchHistory : [] } }) + Rpc.register(backend.registry, "files", { + status: function() { + return { available: fileIndex.available, entries: fileIndex.entries.length, refreshedAt: fileIndex.refreshedAt || null, home: fileIndex.home } + } + }) + // Names from Raycast's calculator namespace (ref 02 §2). Rpc.register(backend.registry, "calculator", { calculate: function(data) { return backend.calculate(String(data && data.query || "")) }, @@ -553,6 +607,7 @@ Item { if (method === "window.visibilityChanged" && data && data.visible) { menuSource.refresh() calculatorData.refresh() + fileIndex.refresh() } } diff --git a/core/FileSearch.js b/core/FileSearch.js new file mode 100644 index 0000000..946d37d --- /dev/null +++ b/core/FileSearch.js @@ -0,0 +1,181 @@ +// File search over a listing of the home directory (SPEC §8.6) [D]: +// services/FileIndex.qml lists files and folders with `fd` (it honours +// .gitignore and skips hidden paths) and their modification times. This +// module parses that listing and matches the query in-process, so the query +// never reaches another process's argv (CLAUDE.md ground rule 6). +// The SPEC plans a Rust indexer with inotify. fd lists ~50k paths in ~25 ms, +// so a fresh listing per launcher open replaces the index and the watcher. + +var DEFAULT_LIMIT = 5 +var MIN_QUERY_LENGTH = 2 +var MAX_ENTRIES = 200000 + +// stat --printf '%F|%Y|%n\0' output: type, mtime in seconds, absolute path. +function parseListing(text, home, maxEntries) { + var out = [] + var limit = maxEntries || MAX_ENTRIES + var prefix = String(home || "").replace(/\/+$/, "") + "/" + var records = String(text || "").split("\0") + for (var i = 0; i < records.length && out.length < limit; i++) { + var rec = records[i] + var a = rec.indexOf("|") + var b = a < 0 ? -1 : rec.indexOf("|", a + 1) + if (b < 0) continue + var path = rec.slice(b + 1) + if (path.indexOf(prefix) !== 0 || path.length === prefix.length) continue + var rel = path.slice(prefix.length).replace(/\/+$/, "") + var slash = rel.lastIndexOf("/") + var name = rel.slice(slash + 1) + out.push({ + path: prefix + rel, + rel: rel, + name: name, + dir: slash < 0 ? "" : rel.slice(0, slash), + folder: rec.slice(0, a) === "directory", + mtime: Number(rec.slice(a + 1, b)) || 0, + lowerName: name.toLowerCase(), + lowerRel: rel.toLowerCase() + }) + } + return out +} + +function stem(lowerName) { + var dot = lowerName.lastIndexOf(".") + return dot > 0 ? lowerName.slice(0, dot) : lowerName +} + +// Start of a word in a file name: after a separator or a lower→upper or +// letter→digit change ("MyReport_v2" has words my, report, v, 2). +function isWordStart(name, at) { + if (at === 0) return true + var prev = name.charAt(at - 1), cur = name.charAt(at) + if (/[\s._\-()[\]+,]/.test(prev)) return true + if (/[a-z]/.test(prev) && /[A-Z]/.test(cur)) return true + return (/[A-Za-z]/.test(prev) && /[0-9]/.test(cur)) || (/[0-9]/.test(prev) && /[A-Za-z]/.test(cur)) +} + +// Score of one query token against one entry; 0 = no match. +function tokenScore(entry, token) { + var s = stem(entry.lowerName) + if (s === token || entry.lowerName === token) return 100 + if (entry.lowerName.indexOf(token) === 0) return 80 + var at = entry.lowerName.indexOf(token) + while (at > 0) { + if (isWordStart(entry.name, at)) return 60 + at = entry.lowerName.indexOf(token, at + 1) + } + if (entry.lowerName.indexOf(token) > 0) return 40 + return entry.lowerRel.indexOf(token) >= 0 ? 10 : 0 +} + +// Newer files rank higher between equal name matches. +function recencyBonus(mtime, nowMs) { + var days = (nowMs / 1000 - mtime) / 86400 + if (days < 1) return 12 + if (days < 7) return 8 + if (days < 30) return 4 + return 0 +} + +// Entries whose name or path holds every query token, best first. Each +// token must match; at least one must match the name, so a query that +// matches only folder names returns those folders, not their contents. +function search(entries, query, nowMs, limit) { + var q = String(query || "").trim().toLowerCase() + if (q.length < MIN_QUERY_LENGTH) return [] + var tokens = q.split(/\s+/) + var max = limit || DEFAULT_LIMIT + var hits = [] + for (var i = 0; i < entries.length; i++) { + var e = entries[i] + if (e.lowerRel.indexOf(tokens[0]) < 0) continue + var total = 0, inName = false, ok = true + for (var t = 0; t < tokens.length; t++) { + var s = tokenScore(e, tokens[t]) + if (!s) { ok = false; break } + if (s > 10) inName = true + total += s + } + if (!ok || !inName) continue + hits.push({ entry: e, score: total + recencyBonus(e.mtime, nowMs) }) + } + hits.sort(function(a, b) { + return b.score - a.score || a.entry.rel.length - b.entry.rel.length || b.entry.mtime - a.entry.mtime + }) + return hits.slice(0, max).map(function(h) { return h.entry }) +} + +// Nerd Font glyphs (Font Awesome set), as other omacast rows use. +var GLYPHS = { + folder: "", pdf: "", image: "", code: "", text: "", + archive: "", audio: "", video: "", word: "", sheet: "", + slides: "", file: "" +} +var KINDS = { + pdf: "pdf", + png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", heic: "image", avif: "image", bmp: "image", + js: "code", ts: "code", jsx: "code", tsx: "code", py: "code", ipynb: "code", java: "code", c: "code", h: "code", cpp: "code", + rs: "code", go: "code", rb: "code", sh: "code", lua: "code", qml: "code", json: "code", html: "code", css: "code", sql: "code", + yml: "code", yaml: "code", toml: "code", r: "code", + txt: "text", md: "text", log: "text", csv: "sheet", tex: "text", org: "text", + zip: "archive", tar: "archive", gz: "archive", xz: "archive", zst: "archive", "7z": "archive", rar: "archive", + mp3: "audio", flac: "audio", wav: "audio", ogg: "audio", m4a: "audio", opus: "audio", + mp4: "video", mkv: "video", webm: "video", mov: "video", avi: "video", + doc: "word", docx: "word", odt: "word", rtf: "word", + xls: "sheet", xlsx: "sheet", ods: "sheet", + ppt: "slides", pptx: "slides", odp: "slides", key: "slides" +} + +function glyphFor(entry) { + if (entry.folder) return GLYPHS.folder + var dot = entry.lowerName.lastIndexOf(".") + return GLYPHS[KINDS[dot > 0 ? entry.lowerName.slice(dot + 1) : ""] || "file"] +} + +// The parent folders that tell same-named files apart: the last two, or +// the whole path from ~ when it is that short ("~ › Personal"). +function breadcrumb(dir) { + if (!dir) return "~" + var parts = dir.split("/") + if (parts.length > 2) return parts.slice(-2).join(" › ") + return "~ › " + parts.join(" › ") +} + +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +// Last change: relative within a week ("Yesterday"), then a date ("24 Aug", +// or "Aug 2025" from an earlier year), which scans faster than "77 days ago". +function modifiedLabel(ms, nowMs, relativeTime) { + if (nowMs - ms < 7 * 86400000) return relativeTime(ms, nowMs) + var d = new Date(ms), now = new Date(nowMs) + if (d.getFullYear() === now.getFullYear()) return d.getDate() + " " + MONTHS[d.getMonth()] + return MONTHS[d.getMonth()] + " " + d.getFullYear() +} + +function candidate(entry, nowMs, relativeTime) { + return { + id: "file:" + entry.path, + type: "file", + kind: entry.folder ? "folder" : "file", + title: entry.name, + subtitle: breadcrumb(entry.dir), + typeLabel: modifiedLabel(entry.mtime * 1000, nowMs, relativeTime), + glyph: glyphFor(entry), + path: entry.path, + rootSearch: { when: "searching" } + } +} + +if (typeof module !== "undefined") { + module.exports = { + DEFAULT_LIMIT: DEFAULT_LIMIT, + MIN_QUERY_LENGTH: MIN_QUERY_LENGTH, + parseListing: parseListing, + search: search, + glyphFor: glyphFor, + breadcrumb: breadcrumb, + modifiedLabel: modifiedLabel, + candidate: candidate + } +} diff --git a/core/RootSearch.js b/core/RootSearch.js index b424a03..452daf5 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -292,6 +292,10 @@ function rootSections(candidates, query, ctx, deps) { var sections = [] if (ctx.calculation && !filtered) sections.push({ id: "calculator", title: "Calculator", rows: [{ candidate: calculatorCandidate(ctx.calculation), record: null }] }) if (results.length > 0) sections.push({ id: "results", title: "Results", rows: results }) + // Files (SPEC §7.2) come after Results, as in Raycast's root search [O]; + // ctx.files holds FileSearch candidates, already ranked and limited. + if (ctx.files && ctx.files.length && !filtered) + sections.push({ id: "files", title: "Files", rows: ctx.files.map(function(c) { return { candidate: c, record: null } }) }) if (!filtered) sections.push({ id: "fallback-commands", title: "Use “" + q + "” with…", rows: fallbackRows(q) }) return sections } @@ -351,6 +355,15 @@ function actionsFor(candidate) { } return calc } + // File Search actions (ref 04 App. A file-search: Open, Show in Finder, + // Copy Path) [O]; the file manager stands in for Finder. + if (candidate.kind === "file" || candidate.kind === "folder") { + return [ + { id: "open", title: candidate.kind === "folder" ? "Open Folder" : "Open File", shortcut: "↵", section: "primary", icon: "" }, + { id: "show-in-files", title: "Show in Files", shortcut: "Ctrl ↵", section: "primary", icon: "" }, + { id: "copy-path", title: "Copy Path", shortcut: "Ctrl Shift C", section: "copy", icon: "" } + ] + } var primary var list = [] switch (candidate.kind) { @@ -456,6 +469,12 @@ function aboutFor(c, nowMs) { add("Saved", c.typeLabel) return rows } + if (c.kind === "file" || c.kind === "folder") { + add("Name", c.title) + add("Where", c.path) + add("Modified", c.typeLabel) + return rows + } add("Title", c.title) add("Type", c.typeLabel) if (c.kind !== "fallback") add("Location", c.subtitle) @@ -494,6 +513,7 @@ if (typeof module !== "undefined") { calculatorCandidate: calculatorCandidate, historyCandidate: historyCandidate, historySections: historySections, + relativeTime: relativeTime, decorate: decorate, rootSections: rootSections, submenuSections: submenuSections, diff --git a/services/FileIndex.qml b/services/FileIndex.qml new file mode 100644 index 0000000..2b7b543 --- /dev/null +++ b/services/FileIndex.qml @@ -0,0 +1,58 @@ +import QtQuick +import Quickshell.Io +import "../core/FileSearch.js" as FileSearch + +// The listing that file search matches against (core/FileSearch.js). fd +// lists the home directory with fixed arguments only: the query is matched +// in-process and never reaches an argv. A refresh takes ~55 ms for ~25k +// entries, so it reruns each time the launcher opens, if older than +// maxAgeMs, instead of keeping an index and an inotify watcher [D]. +Item { + id: index + + property string home: "" + property int maxAgeMs: 30000 + property var entries: [] + property double refreshedAt: 0 + property bool available: true + signal message(string level, string text, var metadata) + + // Build output, caches and environments that fd's ignore files miss in + // projects that are not git repositories (SPEC §8.6 default excludes). + readonly property var excludes: ["node_modules", "__pycache__", ".venv", "venv", "site-packages", "target", ".cache", ".git"] + + function refresh() { + if (!index.home || !index.available || lister.running) return + if (Date.now() - index.refreshedAt < index.maxAgeMs) return + lister.running = true + } + + Process { + id: lister + command: { + // Bash turns a missing fd into exit 127 instead of a failed start. + var argv = ["bash", "-c", 'command -v fd >/dev/null || exit 127; exec "$@"', "omacast-files", + "fd", "--type", "f", "--type", "d", "--absolute-path"] + for (var i = 0; i < index.excludes.length; i++) argv.push("--exclude", index.excludes[i]) + return argv.concat([".", index.home, "--exec-batch", "stat", "--printf", "%F|%Y|%n\\0"]) + } + stdout: StdioCollector { + onStreamFinished: { + var t0 = Date.now() + index.entries = FileSearch.parseListing(text, index.home) + index.refreshedAt = Date.now() + index.message("debug", "listed " + index.entries.length + " files", { parseMs: index.refreshedAt - t0 }) + } + } + onExited: function(exitCode) { + // 127: fd is not installed. File search then stays off until the + // shell reloads the plugin, instead of failing on every open. + if (exitCode === 127) { + index.available = false + index.message("warning", "fd not found; file search is off", null) + } else if (exitCode !== 0) { + index.message("warning", "file listing exited with " + exitCode, null) + } + } + } +} diff --git a/tests/file-search.test.js b/tests/file-search.test.js new file mode 100644 index 0000000..837b6dd --- /dev/null +++ b/tests/file-search.test.js @@ -0,0 +1,103 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const FS = require("../core/FileSearch.js") + +process.env.TZ = "UTC" + +const HOME = "/home/ada" +const NOW = Date.UTC(2026, 10, 9, 12, 0) // 9 Nov 2026 +const day = 86400 +const t = (daysAgo) => Math.round(NOW / 1000 - daysAgo * day) + +// Hand-written `stat --printf '%F|%Y|%n\0'` output. +function listing(rows) { + return rows.map(([type, daysAgo, path]) => `${type}|${t(daysAgo)}|${path}`).join("\0") + "\0" +} + +const entries = FS.parseListing(listing([ + ["regular file", 40, "/home/ada/Personal/Uni/Report.pdf"], + ["regular file", 2, "/home/ada/Personal/Uni/Draft report v2.docx"], + ["regular file", 400, "/home/ada/Personal/Uni/MyReportNotes.md"], + ["regular file", 1, "/home/ada/Downloads/quarterly-reports.zip"], + ["directory", 10, "/home/ada/Personal/Reports"], + ["regular file", 3, "/home/ada/Personal/Reports/summary.txt"], + ["regular file", 5, "/home/ada/Projects/omacast/src/report.js"], + ["regular file", 5, "/etc/passwd"] +]), HOME) + +const names = (q, limit) => FS.search(entries, q, NOW, limit || 10).map((e) => e.name) + +test("parseListing keeps home paths and reads type, time and folders", () => { + assert.equal(entries.length, 7, "/etc/passwd is outside home and dropped") + const report = entries[0] + assert.equal(report.name, "Report.pdf") + assert.equal(report.dir, "Personal/Uni") + assert.equal(report.rel, "Personal/Uni/Report.pdf") + assert.equal(report.mtime, t(40)) + assert.equal(report.folder, false) + assert.equal(entries[4].folder, true) + assert.equal(entries[4].name, "Reports") +}) + +test("parseListing survives empty input, junk records and | in names", () => { + assert.deepEqual(FS.parseListing("", HOME), []) + const odd = FS.parseListing("junk\0regular file|100|/home/ada/a|b.txt\0", HOME) + assert.equal(odd.length, 1) + assert.equal(odd[0].name, "a|b.txt") +}) + +// Worked by hand from the rules: exact stem 100, name prefix 80, word start +// 60, inside the name 40, path only 10; +12 under a day, +8 under a week, +// +4 under a month; ties go to the shorter path. +// report.js 100+8, Report.pdf 100+0, Reports/ 80+4, +// quarterly-reports.zip 60+8 (31 chars), Draft report v2.docx 60+8 (33), +// MyReportNotes.md 60+0. summary.txt matches only its folder: left out. +test("'report' ranks exact names, then prefixes, then word starts", () => { + assert.deepEqual(names("report"), [ + "report.js", "Report.pdf", "Reports", + "quarterly-reports.zip", "Draft report v2.docx", "MyReportNotes.md" + ]) +}) + +// uni (path, 10) + report: Report.pdf 10+100, Draft 10+60+8, MyReportNotes 10+60. +test("every token must match, and the name must hold at least one", () => { + assert.deepEqual(names("uni report"), ["Report.pdf", "Draft report v2.docx", "MyReportNotes.md"]) + assert.deepEqual(names("reports summary"), ["summary.txt"]) + assert.deepEqual(names("uni zzz"), []) +}) + +test("recency breaks ties between equal name matches", () => { + const two = FS.parseListing(listing([ + ["regular file", 300, "/home/ada/a/notes.md"], + ["regular file", 0.5, "/home/ada/b/notes.md"] + ]), HOME) + assert.deepEqual(FS.search(two, "notes", NOW, 5).map((e) => e.dir), ["b", "a"]) +}) + +test("short queries return nothing and the limit holds", () => { + assert.deepEqual(names("r"), []) + assert.equal(FS.search(entries, "report", NOW, 2).length, 2) +}) + +test("glyphs, breadcrumbs and the row candidate", () => { + assert.equal(FS.glyphFor(entries[0]), "", "pdf") + assert.equal(FS.glyphFor(entries[4]), "", "folder") + assert.equal(FS.glyphFor({ folder: false, lowerName: "noext" }), "") + assert.equal(FS.breadcrumb(""), "~") + assert.equal(FS.breadcrumb("Personal"), "~ › Personal") + assert.equal(FS.breadcrumb("Personal/Uni"), "~ › Personal › Uni") + assert.equal(FS.breadcrumb("a/b/c/d/e"), "d › e") + const c = FS.candidate(entries[0], NOW, () => "relative") + assert.equal(c.id, "file:/home/ada/Personal/Uni/Report.pdf") + assert.equal(c.title, "Report.pdf") + assert.equal(c.subtitle, "~ › Personal › Uni") + assert.equal(c.typeLabel, "30 Sep", "40 days before 9 Nov 2026") + assert.equal(c.kind, "file") +}) + +test("modified label: relative for a week, then day and month, then month and year", () => { + const rel = (ms, now) => `rel ${Math.round((now - ms) / 86400000)}d` + assert.equal(FS.modifiedLabel(NOW - 2 * day * 1000, NOW, rel), "rel 2d") + assert.equal(FS.modifiedLabel(NOW - 40 * day * 1000, NOW, rel), "30 Sep") + assert.equal(FS.modifiedLabel(NOW - 400 * day * 1000, NOW, rel), "Oct 2025") +}) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..f9eff99 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -625,7 +625,7 @@ PanelWindow { if (key === Qt.Key_PageUp) { window.move(-8); return true } if (key === Qt.Key_PageDown) { window.move(8); return true } if (ctrl && shift && key === Qt.Key_F) { window.performIfOffered(["favorite-add", "favorite-remove"]); return true } - if (ctrl && shift && key === Qt.Key_C) { window.performIfOffered(["copy-command", "copy-id", "copy-question-answer"]); return true } + if (ctrl && shift && key === Qt.Key_C) { window.performIfOffered(["copy-command", "copy-id", "copy-path", "copy-question-answer"]); return true } if (ctrl && shift && key === Qt.Key_D) { window.performIfOffered(["delete-all-entries"]); return true } if (ctrl && key === Qt.Key_D) { window.performIfOffered(["delete-entry"]); return true } if (alt && shift && key === Qt.Key_D) { window.performIfOffered(["disable"]); return true } @@ -652,6 +652,12 @@ PanelWindow { function onCandidatesRevisionChanged() { if (window.shown && !window.actionPanelOpen && !window.alert) window.rebuild(false) } // Exchange rates or zone offsets arrived: re-evaluate a calculation on screen. function onCalculatorRevisionChanged() { if (window.shown && !window.actionPanelOpen && window.query) window.rebuild(false) } + // Files arrive after the first answer and take the top spot when nothing + // else matched; follow them unless the user has moved off the first row. + function onFilesRevisionChanged() { + if (!window.shown || window.actionPanelOpen || !window.query) return + window.rebuild(window.selectedIndex === window.nextSelectable(-1, 1)) + } } From aa70549ec479c73d7bba30ab0c25b72c4e9daf79 Mon Sep 17 00:00:00 2001 From: Assem130 <104645338+Assem130@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:11:07 +0100 Subject: [PATCH 2/2] Add Search Files view, learned file ranking and doctor check - Search Files: a built-in command and a "Search All Files" action on file rows (carries the query). A 40/60 list and detail pane; before typing it lists recently changed files. The pane previews images and the head of text and code files (watched for edits), then Name, Where, Type, Size and Modified. - Files opened or shown from omacast get a capped frecency bonus (15 x log10(score), max 40), stored like app visits. - Copy Path answers Ctrl+Shift+C. - When commands load after the first answer (right after a reload), the selection moves to the first row unless the user moved it, so a fast Enter no longer lands on a web fallback. - omacast doctor reports fd and the listing size. - README, SPEC 8.6 and CLAUDE.md status describe the design. --- Backend.qml | 58 ++++++++++++-- CLAUDE.md | 8 +- README.md | 13 +++- SPEC.md | 1 + bin/omacast | 8 ++ core/FileSearch.js | 89 +++++++++++++++++++-- core/RootSearch.js | 14 +++- services/FileIndex.qml | 2 +- tests/file-search.test.js | 53 ++++++++++++- ui/FileDetail.qml | 159 ++++++++++++++++++++++++++++++++++++++ ui/LauncherWindow.qml | 49 +++++++++++- 11 files changed, 426 insertions(+), 28 deletions(-) create mode 100644 ui/FileDetail.qml diff --git a/Backend.qml b/Backend.qml index 9a6f4b5..bee1abe 100644 --- a/Backend.qml +++ b/Backend.qml @@ -131,19 +131,53 @@ Item { // Rows from the last search, by id, so actions can find their path. property var fileRows: ({}) + property var fileEntries: ({}) // Bumped when a new listing arrives, so an open search shows its files. readonly property double filesRevision: fileIndex.refreshedAt function fileCandidates(query, nowMs) { - var rows = FileSearch.search(fileIndex.entries, query, nowMs, FileSearch.DEFAULT_LIMIT) - .map(function(e) { return FileSearch.candidate(e, nowMs, RootSearch.relativeTime) }) - var byId = ({}) - for (var i = 0; i < rows.length; i++) byId[rows[i].id] = rows[i] + var visits = frecencyStore.loaded ? frecencyStore.doc.items : ({}) + var bonus = function(e) { + var v = visits["file:" + e.path] + return v ? FileSearch.visitBonus(Frecency.score(v.anchor, nowMs / 1000)) : 0 + } + return backend.keepFileRows(FileSearch.search(fileIndex.entries, query, nowMs, FileSearch.DEFAULT_LIMIT, bonus), nowMs, false) + } + + function keepFileRows(entries, nowMs, inFileView) { + var rows = [], byId = ({}), entryById = ({}) + for (var i = 0; i < entries.length; i++) { + var c = FileSearch.candidate(entries[i], nowMs, RootSearch.relativeTime) + // The Search Files list is narrow: the detail pane shows the rest. + if (inFileView) { c.subtitle = ""; c.typeLabel = ""; c.inFileView = true } + rows.push(c) + byId[c.id] = c + entryById[c.id] = entries[i] + } backend.fileRows = byId + backend.fileEntries = entryById return rows } - function fileAction(action, row) { + // Search Files: recently changed files before typing, then up to 50 matches. + function fileViewSections(query, nowMs) { + var q = String(query || "").trim() + if (q.length < FileSearch.MIN_QUERY_LENGTH) { + var recent = backend.keepFileRows(FileSearch.recent(fileIndex.entries, 30), nowMs, true) + return recent.length ? [{ id: "recent-files", title: "Recently Changed", rows: recent.map(function(c) { return { candidate: c } }) }] : [] + } + var visits = frecencyStore.loaded ? frecencyStore.doc.items : ({}) + var bonus = function(e) { + var v = visits["file:" + e.path] + return v ? FileSearch.visitBonus(Frecency.score(v.anchor, nowMs / 1000)) : 0 + } + var rows = backend.keepFileRows(FileSearch.search(fileIndex.entries, q, nowMs, 50, bonus), nowMs, true) + return rows.length ? [{ id: "files", title: "Files", rows: rows.map(function(c) { return { candidate: c } }) }] : [] + } + + function fileAction(action, row, query) { + // Opening a file, or showing it, teaches the ranking (as for apps). + if (action === "open" || action === "show-in-files") backend.recordVisit(row.id, query) switch (action) { case "open": // gio, not xdg-open: xdg-open runs a Terminal=true default (nvim) @@ -157,6 +191,8 @@ Item { case "copy-path": clipboard.copy(row.path) return { close: true, hud: "Copied to Clipboard" } + case "search-files": + return { push: { kind: "file-search", title: "Search Files", query: query } } } throw new Error("Unknown action " + action) } @@ -469,6 +505,10 @@ Item { if (data && data.fleaHits) ctx.fleaHits = data.fleaHits var sections if (view && view.kind === "menu") sections = RootSearch.submenuSections(backend.candidates, view.menuId, query, ctx, backend.deps) + else if (view && view.kind === "file-search") { + fileIndex.refresh() + sections = backend.fileViewSections(query, ctx.nowMs) + } else if (view && view.kind === "calculator-history") sections = RootSearch.historySections(calculatorStore.doc.history, query, ctx.nowMs, Calculator.filterHistory) else { ctx.filter = String(data && data.filter || "all") @@ -538,13 +578,19 @@ Item { return backend.calculatorAction(String(data.action || "open"), entry, entry.id) } var fileRow = data && backend.fileRows[String(data.id)] - if (fileRow) return backend.fileAction(String(data.action || "open"), fileRow) + if (fileRow) return backend.fileAction(String(data.action || "open"), fileRow, String(data.query || "")) return backend.performAction(data) }, getSearchHistory: function() { return frecencyStore.loaded ? frecencyStore.doc.searchHistory : [] } }) Rpc.register(backend.registry, "files", { + // The Search Files detail pane, for a row of the last search. + details: function(data) { + var entry = backend.fileEntries[String(data && data.id || "")] + if (!entry) throw new Error("Unknown file " + (data && data.id)) + return FileSearch.details(entry, Date.now(), RootSearch.relativeTime) + }, status: function() { return { available: fileIndex.available, entries: fileIndex.entries.length, refreshedAt: fileIndex.refreshedAt || null, home: fileIndex.home } } diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..a8ca7e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,8 +149,14 @@ Works: config.json sets `remToPxRate` (16), `workHoursPerDay` (8) and `clockFormat` (`auto` follows the Qt locale, or `12h` / `24h`). ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. +- **File search** (SPEC §8.6, `core/FileSearch.js`, `services/FileIndex.qml`, `ui/FileDetail.qml`) [D]: no index or watcher. On open, if the listing is over 30 s old, `fd` + `stat` list `$HOME` with fixed arguments (~55 ms for ~25k entries; default excludes for `node_modules`, `.venv`, `venv`, `__pycache__`, `site-packages`, `target`, `.cache`, `.git`), and the query is matched in-process, so it never reaches an argv. + - Root search: a **Files** section after Results, 5 rows. Ranking: exact name 100, name prefix 80, word start 60, inside the name 40, path only 10; +12/+8/+4 for changes within a day/week/month; plus `15 × log10(frecency)` (max 40) for files opened from omacast. Every token must match and one must match the name. + - **Search Files** (built-in command, and Search All Files in a file row's action panel, which carries the query): a 40/60 list and detail pane. Before typing it lists the 30 most recently changed files. The pane previews images and the first 4000 characters of text and code under 1 MB (FileView with `watchChanges`), then Name, Where, Type, Size, Modified. + - Actions: Open File / Open Folder (`gio open`, which gives a `Terminal=true` default such as nvim a terminal; `xdg-open` does not), Show in Files (`nautilus --select`), Copy Path (Ctrl+Shift+C). Open and Show record a frecency visit under `file:`. + - RPC `files.status` and `files.details`. `omacast doctor` reports fd and the listing size. + - A listing that lands after the first answer, and commands that load after it (right after a reload), now move the selection to the first row unless the user moved it. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). -- **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). +- **Tests:** root search, calculator, file search and privacy coverage (`node --test tests/*.test.js`). **Default-agent hand-off:** removed 2026-11-02 after the marketplace review; it exposed the question in process arguments (SPEC §2.0). diff --git a/README.md b/README.md index 5900ced..842b1c3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ It learns what you use, searches your apps and every Omarchy action, and looks l Omarchy's built-in menu is great at what it does. omacast adds what Raycast users miss on Linux: * **Learns your habits.** Results are ranked with a port of Raycast's root-search algorithm, plus frecency and learned search terms. The things you actually use float to the top, and if you type `ff` and pick Firefox, `ff` keeps meaning Firefox. -* **One search for everything.** Your apps, all ~400 Omarchy menu actions (with breadcrumbs and live ✓ state), and web-search fallbacks. +* **One search for everything.** Your apps, all ~400 Omarchy menu actions (with breadcrumbs and live ✓ state), the files in your home folder, and web-search fallbacks. +* **File search.** Matching files and folders show up under Files. Search Files opens a full list with a preview of images and text, and the files you open rise to the top. * **Your shortcuts.** Give anything an alias, pin favorites, and hide the commands you never touch. * **Keyboard-first.** A Ctrl+K action panel, Ctrl+1–9 quick picks, search history on ↑, section jumping, and emacs-style navigation. * **Native to Omarchy.** It runs inside `omarchy-shell` as a plugin, with no Electron, no Node and no extra daemons. It opens in about 40 ms and follows your theme live when you run `omarchy theme set`. @@ -84,13 +85,17 @@ and `~/.local/state/omacast`. ### Dependencies and network access -omacast needs only what Omarchy ships: `omarchy-shell` (Quickshell), Hyprland, bash, `jq` and `socat`. +omacast needs only what Omarchy ships: `omarchy-shell` (Quickshell), Hyprland, bash, `jq`, `socat` and `fd`. It adds no packages and no background services. - **Currency rates.** When you open the launcher and its rates are over 12 hours old, the calculator downloads ECB exchange rates from `api.frankfurter.dev` (retrying 30 minutes after a failure) and caches them in `~/.cache/omacast/currencies.json`. This is the only network request omacast makes. Turn it off with `"currencyRates": false` in `~/.config/omacast/config.json`. +- **File search.** When you open the launcher, and its list is over 30 seconds old, omacast lists your home folder + with `fd` and matches your query in memory. Nothing is indexed or written to disk, and your query never goes on a + command line. Hidden folders, git-ignored files, `node_modules`, Python environments, caches and build output + (`target`) are left out. Without `fd`, the Files section stays off and `omacast doctor` says so. - **Optional search helper.** With Rust (`cargo`) installed, `omacast install` (or `omacast build-search`) builds the small **flea-search** helper from `search/` on your machine. It matches titles, alternate names, subtitles and keywords outside the shell's UI thread and tolerates transposed or missing letters. Nothing is @@ -127,7 +132,7 @@ the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` mo | `Ctrl+↑` / `Ctrl+↓` | Jump between sections | | `Ctrl+N` / `Ctrl+P` | Next / previous | | `Ctrl+Shift+F` | Add to favorites (`Ctrl+Alt+↑↓` to reorder) | -| `Ctrl+Shift+C` | Copy | +| `Ctrl+Shift+C` | Copy (desktop ID, command, file path or answer) | | `Alt+Shift+D` | Disable a command | | `Esc` / `Shift+Esc` | Back / back to root | | `Ctrl +` / `Ctrl −` / `Ctrl 0` | Zoom | @@ -143,7 +148,7 @@ omacast doctor ## Roadmap -omacast is early. Search, ranking and the Omarchy integration are done. Coming next: +omacast is early. Search, ranking, file search and the Omarchy integration are done. Coming next: - [ ] Settings window - [ ] Script Commands diff --git a/SPEC.md b/SPEC.md index 4e3a823..e84d04c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -537,6 +537,7 @@ Grammar: `{keyword opt="v" opt2='v' | modifier | modifier}`. Quotes accepted: `" - Settings [O]: scopes, ignore patterns, include hidden, use ignore files, index removable drives, content search. - Content search (P2): extract text via `pdftotext`, docx/odt via zip+xml, plain text, OCR images. Run extraction in a **separate process**, as Raycast does for crash isolation [O]. - Results: name match first, then path, sorted by recency. Detail pane: preview (image, PDF first page, text head) plus metadata. Quick Look = a large preview window, or GNOME Sushi if installed. +- **Built [D], 2026-11-09:** no Rust indexer, SQLite or inotify. `fd` (in Omarchy's base packages) lists `$HOME` in ~55 ms for ~25k entries, so the plugin relists on open when the listing is over 30 s old and matches in-process. Previews cover images and text; PDF first pages, content search, scope settings and Quick Look are not built. See CLAUDE.md Status. --- diff --git a/bin/omacast b/bin/omacast index f6ab97e..b8272de 100755 --- a/bin/omacast +++ b/bin/omacast @@ -293,6 +293,14 @@ cmd_doctor() { check "plugin is enabled" bash -c "omarchy plugin list --json 2>/dev/null | jq -e --arg id '$PLUGIN_ID' 'any(.[]; .id == \$id and .enabled)'" check "plugin answers IPC" bash -c "[[ \$(omarchy-shell $IPC_TARGET ping) == ok ]]" check "backend answers system.healthPing" bash -c "omarchy-shell $IPC_TARGET call system.healthPing '{}' | grep -q '\"ok\":true'" + # File search lists the home folder with fd; without it the Files section is off. + if command -v fd >/dev/null 2>&1; then + local files + files=$(omarchy-shell "$IPC_TARGET" call files.status '{}' 2>/dev/null | jq -r 'if .ok then (if .data.refreshedAt then "\(.data.entries) files listed" else "not listed yet; opens with the launcher" end) else empty end' 2>/dev/null) + printf ' ok fd is installed (file search%s)\n' "${files:+: $files}" + else + printf ' info fd is not installed, so file search is off (install it with: omarchy pkg add fd)\n' + fi if [[ -x $repo/bin/omacast-search ]]; then printf ' ok flea-search helper is built\n' else diff --git a/core/FileSearch.js b/core/FileSearch.js index 946d37d..6c0bf63 100644 --- a/core/FileSearch.js +++ b/core/FileSearch.js @@ -10,7 +10,8 @@ var DEFAULT_LIMIT = 5 var MIN_QUERY_LENGTH = 2 var MAX_ENTRIES = 200000 -// stat --printf '%F|%Y|%n\0' output: type, mtime in seconds, absolute path. +// stat --printf '%F|%Y|%s|%n\0' output: type, mtime in seconds, size in +// bytes, absolute path. The path is last, so a "|" in it is harmless. function parseListing(text, home, maxEntries) { var out = [] var limit = maxEntries || MAX_ENTRIES @@ -20,8 +21,9 @@ function parseListing(text, home, maxEntries) { var rec = records[i] var a = rec.indexOf("|") var b = a < 0 ? -1 : rec.indexOf("|", a + 1) - if (b < 0) continue - var path = rec.slice(b + 1) + var c = b < 0 ? -1 : rec.indexOf("|", b + 1) + if (c < 0) continue + var path = rec.slice(c + 1) if (path.indexOf(prefix) !== 0 || path.length === prefix.length) continue var rel = path.slice(prefix.length).replace(/\/+$/, "") var slash = rel.lastIndexOf("/") @@ -33,6 +35,7 @@ function parseListing(text, home, maxEntries) { dir: slash < 0 ? "" : rel.slice(0, slash), folder: rec.slice(0, a) === "directory", mtime: Number(rec.slice(a + 1, b)) || 0, + size: Number(rec.slice(b + 1, c)) || 0, lowerName: name.toLowerCase(), lowerRel: rel.toLowerCase() }) @@ -78,10 +81,21 @@ function recencyBonus(mtime, nowMs) { return 0 } +// Bonus for files the user opened from omacast, from their frecency score +// (core/Frecency.js: ~100 after one open, halving every 10 days). One +// recent open lifts a prefix match (80) above an unopened exact name (100). +var MAX_VISIT_BONUS = 40 + +function visitBonus(frecencyScore) { + if (!(frecencyScore > 1)) return 0 + return Math.min(MAX_VISIT_BONUS, 15 * Math.log10(frecencyScore)) +} + // Entries whose name or path holds every query token, best first. Each // token must match; at least one must match the name, so a query that // matches only folder names returns those folders, not their contents. -function search(entries, query, nowMs, limit) { +// bonus(entry), when given, adds a number to an entry's score. +function search(entries, query, nowMs, limit, bonus) { var q = String(query || "").trim().toLowerCase() if (q.length < MIN_QUERY_LENGTH) return [] var tokens = q.split(/\s+/) @@ -98,7 +112,7 @@ function search(entries, query, nowMs, limit) { total += s } if (!ok || !inName) continue - hits.push({ entry: e, score: total + recencyBonus(e.mtime, nowMs) }) + hits.push({ entry: e, score: total + recencyBonus(e.mtime, nowMs) + (bonus ? bonus(e) : 0) }) } hits.sort(function(a, b) { return b.score - a.score || a.entry.rel.length - b.entry.rel.length || b.entry.mtime - a.entry.mtime @@ -127,10 +141,63 @@ var KINDS = { ppt: "slides", pptx: "slides", odp: "slides", key: "slides" } -function glyphFor(entry) { - if (entry.folder) return GLYPHS.folder +function kindOf(entry) { + if (entry.folder) return "folder" + var dot = entry.lowerName.lastIndexOf(".") + return KINDS[dot > 0 ? entry.lowerName.slice(dot + 1) : ""] || "file" +} + +function glyphFor(entry) { return GLYPHS[kindOf(entry)] } + +var KIND_NAMES = { + folder: "Folder", pdf: "PDF document", image: "Image", code: "Source code", text: "Text", + archive: "Archive", audio: "Audio", video: "Video", word: "Document", sheet: "Spreadsheet", + slides: "Presentation", file: "File" +} + +// "12 KB", "3.4 MB": decimal units, as file managers on Linux show them. +function sizeLabel(bytes) { + if (!(bytes >= 0)) return "" + if (bytes < 1000) return bytes + (bytes === 1 ? " byte" : " bytes") + var units = ["KB", "MB", "GB", "TB"], v = bytes / 1000, i = 0 + while (v >= 1000 && i < units.length - 1) { v /= 1000; i++ } + return (v < 10 ? Math.round(v * 10) / 10 : Math.round(v)) + " " + units[i] +} + +// Files changed most recently, for Search Files before the user types. +function recent(entries, limit) { + var files = [] + for (var i = 0; i < entries.length; i++) if (!entries[i].folder) files.push(entries[i]) + files.sort(function(a, b) { return b.mtime - a.mtime }) + return files.slice(0, limit || 20) +} + +// What the preview pane shows: an image, the head of a text file, or the +// type glyph. Text over 1 MB is not read [D]. +var TEXT_PREVIEW_MAX_BYTES = 1000000 +var IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"] + +function previewKind(entry) { + var kind = kindOf(entry) var dot = entry.lowerName.lastIndexOf(".") - return GLYPHS[KINDS[dot > 0 ? entry.lowerName.slice(dot + 1) : ""] || "file"] + var ext = dot > 0 ? entry.lowerName.slice(dot + 1) : "" + if (kind === "image" && IMAGE_EXTENSIONS.indexOf(ext) >= 0) return "image" + if ((kind === "text" || kind === "code" || ext === "csv") && entry.size <= TEXT_PREVIEW_MAX_BYTES) return "text" + return "glyph" +} + +// Everything the Search Files detail pane shows for one entry. +function details(entry, nowMs, relativeTime) { + return { + path: entry.path, + name: entry.name, + where: entry.dir ? "~/" + entry.dir : "~", + type: KIND_NAMES[kindOf(entry)], + size: entry.folder ? "" : sizeLabel(entry.size), + modified: modifiedLabel(entry.mtime * 1000, nowMs, relativeTime), + glyph: glyphFor(entry), + preview: previewKind(entry) + } } // The parent folders that tell same-named files apart: the last two, or @@ -174,8 +241,14 @@ if (typeof module !== "undefined") { parseListing: parseListing, search: search, glyphFor: glyphFor, + kindOf: kindOf, + sizeLabel: sizeLabel, + recent: recent, + previewKind: previewKind, + details: details, breadcrumb: breadcrumb, modifiedLabel: modifiedLabel, + visitBonus: visitBonus, candidate: candidate } } diff --git a/core/RootSearch.js b/core/RootSearch.js index 452daf5..b154cea 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -39,6 +39,15 @@ var BUILTINS = [ keywords: ["calculator", "calc", "math", "history", "conversions"], run: { push: { kind: "calculator-history", title: "Calculator History" } }, rootSearch: { when: "always" } + }, + { + id: "builtin:search-files", + title: "Search Files", + subtitle: "Files", + glyph: "\uf07c", // nf-fa-folder_open + keywords: ["files", "find", "documents", "folders", "file search"], + run: { push: { kind: "file-search", title: "Search Files" } }, + rootSearch: { when: "always" } } ] @@ -358,11 +367,14 @@ function actionsFor(candidate) { // File Search actions (ref 04 App. A file-search: Open, Show in Finder, // Copy Path) [O]; the file manager stands in for Finder. if (candidate.kind === "file" || candidate.kind === "folder") { - return [ + var fileActions = [ { id: "open", title: candidate.kind === "folder" ? "Open Folder" : "Open File", shortcut: "↵", section: "primary", icon: "" }, { id: "show-in-files", title: "Show in Files", shortcut: "Ctrl ↵", section: "primary", icon: "" }, { id: "copy-path", title: "Copy Path", shortcut: "Ctrl Shift C", section: "copy", icon: "" } ] + // Root search shows five files; this opens the full list with the query. + if (!candidate.inFileView) fileActions.push({ id: "search-files", title: "Search All Files", shortcut: "", section: "configure", icon: "" }) + return fileActions } var primary var list = [] diff --git a/services/FileIndex.qml b/services/FileIndex.qml index 2b7b543..39e2b7a 100644 --- a/services/FileIndex.qml +++ b/services/FileIndex.qml @@ -34,7 +34,7 @@ Item { var argv = ["bash", "-c", 'command -v fd >/dev/null || exit 127; exec "$@"', "omacast-files", "fd", "--type", "f", "--type", "d", "--absolute-path"] for (var i = 0; i < index.excludes.length; i++) argv.push("--exclude", index.excludes[i]) - return argv.concat([".", index.home, "--exec-batch", "stat", "--printf", "%F|%Y|%n\\0"]) + return argv.concat([".", index.home, "--exec-batch", "stat", "--printf", "%F|%Y|%s|%n\\0"]) } stdout: StdioCollector { onStreamFinished: { diff --git a/tests/file-search.test.js b/tests/file-search.test.js index 837b6dd..833bbbf 100644 --- a/tests/file-search.test.js +++ b/tests/file-search.test.js @@ -9,9 +9,9 @@ const NOW = Date.UTC(2026, 10, 9, 12, 0) // 9 Nov 2026 const day = 86400 const t = (daysAgo) => Math.round(NOW / 1000 - daysAgo * day) -// Hand-written `stat --printf '%F|%Y|%n\0'` output. +// Hand-written `stat --printf '%F|%Y|%s|%n\0'` output. function listing(rows) { - return rows.map(([type, daysAgo, path]) => `${type}|${t(daysAgo)}|${path}`).join("\0") + "\0" + return rows.map(([type, daysAgo, path, size]) => `${type}|${t(daysAgo)}|${size || 0}|${path}`).join("\0") + "\0" } const entries = FS.parseListing(listing([ @@ -41,7 +41,7 @@ test("parseListing keeps home paths and reads type, time and folders", () => { test("parseListing survives empty input, junk records and | in names", () => { assert.deepEqual(FS.parseListing("", HOME), []) - const odd = FS.parseListing("junk\0regular file|100|/home/ada/a|b.txt\0", HOME) + const odd = FS.parseListing("junk\0regular file|100|5|/home/ada/a|b.txt\0", HOME) assert.equal(odd.length, 1) assert.equal(odd[0].name, "a|b.txt") }) @@ -101,3 +101,50 @@ test("modified label: relative for a week, then day and month, then month and ye assert.equal(FS.modifiedLabel(NOW - 40 * day * 1000, NOW, rel), "30 Sep") assert.equal(FS.modifiedLabel(NOW - 400 * day * 1000, NOW, rel), "Oct 2025") }) + +test("visit bonus: none unopened, ~30 after one open, capped at 40", () => { + assert.equal(FS.visitBonus(1), 0) + assert.equal(FS.visitBonus(undefined), 0) + assert.equal(FS.visitBonus(100), 30) // 15 * log10(100) + assert.equal(FS.visitBonus(1e6), 40) // 15 * 6 = 90, capped +}) + +// Draft report v2.docx: word start 60 + recent 8 = 68, +30 for an open +// = 98, still under report.js (108) and Report.pdf (100), above Reports (84). +test("a bonus lifts an opened file above unopened prefix matches", () => { + const bonus = (e) => (e.name === "Draft report v2.docx" ? 30 : 0) + assert.deepEqual(FS.search(entries, "report", NOW, 3, bonus).map((e) => e.name), + ["report.js", "Report.pdf", "Draft report v2.docx"]) +}) + +test("size labels use decimal units", () => { + assert.equal(FS.sizeLabel(0), "0 bytes") + assert.equal(FS.sizeLabel(1), "1 byte") + assert.equal(FS.sizeLabel(999), "999 bytes") + assert.equal(FS.sizeLabel(1500), "1.5 KB") + assert.equal(FS.sizeLabel(12000), "12 KB") + assert.equal(FS.sizeLabel(3400000), "3.4 MB") + assert.equal(FS.sizeLabel(2500000000), "2.5 GB") +}) + +test("recent lists files newest first, without folders", () => { + assert.deepEqual(FS.recent(entries, 3).map((e) => e.name), ["quarterly-reports.zip", "Draft report v2.docx", "summary.txt"]) +}) + +test("details and preview kinds for the Search Files pane", () => { + const [img, txt, big, pdf] = FS.parseListing(listing([ + ["regular file", 3, "/home/ada/Pictures/cat.PNG", 2048], + ["regular file", 3, "/home/ada/notes.md", 900], + ["regular file", 3, "/home/ada/huge.log", 5000000], + ["regular file", 40, "/home/ada/Personal/Uni/Report.pdf", 1234567] + ]), HOME) + assert.equal(FS.previewKind(img), "image") + assert.equal(FS.previewKind(txt), "text") + assert.equal(FS.previewKind(big), "glyph", "text over 1 MB is not read") + assert.equal(FS.previewKind(pdf), "glyph") + const d = FS.details(pdf, NOW, () => "relative") + assert.deepEqual(d, { + path: "/home/ada/Personal/Uni/Report.pdf", name: "Report.pdf", where: "~/Personal/Uni", + type: "PDF document", size: "1.2 MB", modified: "30 Sep", glyph: "\uf1c1", preview: "glyph" + }) +}) diff --git a/ui/FileDetail.qml b/ui/FileDetail.qml new file mode 100644 index 0000000..1a6a51a --- /dev/null +++ b/ui/FileDetail.qml @@ -0,0 +1,159 @@ +import QtQuick +import Quickshell.Io +import qs.Commons + +// The Search Files detail pane (ref 04 App. A file-search, "list + detail +// with preview"): a preview on top, then the file's details as label and +// value rows. detail comes from the files.details backend method. +Item { + id: pane + + property var detail: null + property var look: null + property real zoom: 1 + function px(v) { return Math.round(Style.space(v) * pane.zoom) } + + readonly property string preview: pane.detail ? pane.detail.preview : "" + // A file URL with each path segment encoded, so "#" or "?" in a name + // stays part of the path. + readonly property string fileUrl: pane.detail + ? "file://" + pane.detail.path.split("/").map(encodeURIComponent).join("/") : "" + + // The head of a text file. FileView reads the file once per selection; + // core/FileSearch.js keeps files over 1 MB out of this path. + // The text is kept with the path it came from, so a file that loads + // before or after the selection changes never shows under another name. + property string textHead: "" + property string textPath: "" + // Point the reader at the selected file. The same file again keeps the + // text already read: a forced reload() of an unchanged file empties it, + // and watchChanges below keeps an edited file current. + function loadText() { + if (!pane.detail || pane.detail.preview !== "text") return + if (textFile.path !== pane.detail.path) textFile.path = pane.detail.path + else if (pane.textPath !== pane.detail.path) textFile.reload() + } + onDetailChanged: pane.loadText() + Component.onCompleted: pane.loadText() + + FileView { + id: textFile + printErrors: false + watchChanges: true + onFileChanged: reload() + onLoaded: { pane.textHead = text().slice(0, 4000); pane.textPath = path } + onLoadFailed: { pane.textHead = ""; pane.textPath = path } + } + + Item { + id: previewBox + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: Math.round(parent.height * 0.52) + + Image { + anchors.fill: parent + anchors.margins: pane.px(12) + visible: pane.preview === "image" + source: visible ? pane.fileUrl : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + sourceSize.width: width * 2 + sourceSize.height: height * 2 + } + + Rectangle { + anchors.fill: parent + anchors.margins: pane.px(8) + visible: pane.preview === "text" + radius: Math.max(Style.cornerRadius, pane.px(6)) + color: Util.alpha(pane.look.foreground, 0.04) + clip: true + + Text { + anchors.fill: parent + anchors.margins: pane.px(10) + textFormat: Text.PlainText + text: pane.detail && pane.textPath === pane.detail.path ? pane.textHead : "" + color: pane.look.foreground + opacity: 0.8 + font.family: pane.look.fontFamily + font.pixelSize: Math.round(Style.font.caption * pane.zoom) + wrapMode: Text.NoWrap + elide: Text.ElideRight + maximumLineCount: 200 + } + } + + Text { + anchors.centerIn: parent + visible: pane.preview === "glyph" + textFormat: Text.PlainText + text: pane.detail ? pane.detail.glyph : "" + color: pane.look.foreground + opacity: 0.35 + font.family: pane.look.fontFamily + font.pixelSize: pane.px(64) + } + } + + Rectangle { + id: divider + anchors.top: previewBox.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: Util.alpha(pane.look.foreground, 0.12) + } + + Column { + anchors.top: divider.bottom + anchors.topMargin: pane.px(10) + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: pane.px(12) + anchors.rightMargin: pane.px(12) + spacing: pane.px(7) + + Repeater { + model: pane.detail ? [ + { label: "Name", value: pane.detail.name }, + { label: "Where", value: pane.detail.where }, + { label: "Type", value: pane.detail.type }, + { label: "Size", value: pane.detail.size }, + { label: "Modified", value: pane.detail.modified } + ].filter(function(r) { return r.value }) : [] + + delegate: Item { + required property var modelData + width: parent.width + height: valueText.implicitHeight + + Text { + id: labelText + anchors.left: parent.left + textFormat: Text.PlainText + text: modelData.label + color: pane.look.foreground + opacity: 0.5 + font.family: pane.look.fontFamily + font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) + } + Text { + id: valueText + anchors.right: parent.right + width: Math.min(implicitWidth, parent.width - labelText.implicitWidth - pane.px(16)) + horizontalAlignment: Text.AlignRight + textFormat: Text.PlainText + text: modelData.value + color: pane.look.foreground + font.family: pane.look.fontFamily + font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) + elide: Text.ElideMiddle + } + } + } + } +} diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index f9eff99..049fffd 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -63,6 +63,9 @@ PanelWindow { property string query: "" property int selectedIndex: -1 property var selectedRow: null + // The Search Files detail pane's data for the selected row, or null. + property var fileDetail: null + readonly property bool fileView: window.view.kind === "file-search" property int searchSerial: 0 property bool searchPending: false property bool actionPanelOpen: false @@ -98,6 +101,7 @@ PanelWindow { readonly property string placeholder: window.view.kind === "alias" ? "Type an alias, then press ↵" : window.view.kind === "menu" ? "Search " + window.view.title + "…" : window.view.kind === "calculator-history" ? "Search calculations…" + : window.view.kind === "file-search" ? "Search files…" : "Search for apps and commands…" readonly property int cardWidth: Math.min(px(750), width - Style.gapsOut * 2) @@ -158,7 +162,8 @@ PanelWindow { var saved = window.stack.slice() saved[saved.length - 1] = Object.assign({}, window.view, { query: window.query, selectedIndex: window.selectedIndex }) window.stack = saved.concat([next]) - window.query = next.kind === "alias" ? (next.value || "") : "" + // A pushed view can start with a query (Search All Files carries it over). + window.query = next.kind === "alias" ? (next.value || "") : (next.query || "") window.rebuild(true) } @@ -260,6 +265,8 @@ PanelWindow { window.argFocus = -1 } window.argDefs = window.selectedRow && window.selectedRow.argsJson ? JSON.parse(window.selectedRow.argsJson) : [] + var detail = window.fileView && id.indexOf("file:") === 0 ? window.backend.callSync("files.details", { id: id }) : null + window.fileDetail = detail && detail.ok ? detail.data : null if (index >= 0) list.positionViewAtIndex(index, ListView.Contain) if (index === window.nextSelectable(-1, 1)) list.positionViewAtBeginning() } @@ -649,13 +656,19 @@ PanelWindow { Connections { target: window.backend - function onCandidatesRevisionChanged() { if (window.shown && !window.actionPanelOpen && !window.alert) window.rebuild(false) } + // Commands can land after the first answer (right after a reload), when + // only the web fallbacks matched. Follow them, as for files, unless the + // user has moved off the first row. + function onCandidatesRevisionChanged() { + if (!window.shown || window.actionPanelOpen || window.alert) return + window.rebuild(window.selectedIndex === window.nextSelectable(-1, 1)) + } // Exchange rates or zone offsets arrived: re-evaluate a calculation on screen. function onCalculatorRevisionChanged() { if (window.shown && !window.actionPanelOpen && window.query) window.rebuild(false) } // Files arrive after the first answer and take the top spot when nothing // else matched; follow them unless the user has moved off the first row. function onFilesRevisionChanged() { - if (!window.shown || window.actionPanelOpen || !window.query) return + if (!window.shown || window.actionPanelOpen || !(window.query || window.fileView)) return window.rebuild(window.selectedIndex === window.nextSelectable(-1, 1)) } } @@ -882,7 +895,12 @@ PanelWindow { ListView { id: list - anchors.fill: parent + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + // Search Files splits about 40/60 with the detail pane, as + // Raycast's File Search does (37/63). + width: window.fileView ? Math.round(parent.width * 0.4) : parent.width anchors.topMargin: window.px(4) anchors.bottomMargin: window.px(4) visible: window.view.kind !== "alias" @@ -903,6 +921,29 @@ PanelWindow { } } + Rectangle { + id: detailDivider + visible: window.fileView && rows.count > 0 + anchors.left: list.right + anchors.leftMargin: window.px(6) + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: Util.alpha(window.look.foreground, 0.12) + } + + FileDetail { + visible: window.fileView && rows.count > 0 && window.fileDetail !== null + anchors.left: detailDivider.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: window.px(6) + detail: window.fileDetail + look: window.look + zoom: window.zoom + } + Column { anchors.centerIn: parent spacing: window.px(8)