diff --git a/Backend.qml b/Backend.qml index 8895670..bee1abe 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,82 @@ 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: ({}) + 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 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 + } + + // 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) + // 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" } + case "search-files": + return { push: { kind: "file-search", title: "Search Files", query: query } } + } + 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. @@ -428,10 +505,17 @@ 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") - 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 +526,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 +546,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 +577,25 @@ 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, 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 } + } + }) + // 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 +653,7 @@ Item { if (method === "window.visibilityChanged" && data && data.visible) { menuSource.refresh() calculatorData.refresh() + fileIndex.refresh() } } 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 new file mode 100644 index 0000000..6c0bf63 --- /dev/null +++ b/core/FileSearch.js @@ -0,0 +1,254 @@ +// 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|%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 + 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) + 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("/") + 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, + size: Number(rec.slice(b + 1, c)) || 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 +} + +// 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. +// 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+/) + 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) + (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 + }) + 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 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(".") + 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 +// 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, + 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 b424a03..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" } } ] @@ -292,6 +301,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 +364,18 @@ 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") { + 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 = [] switch (candidate.kind) { @@ -456,6 +481,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 +525,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..39e2b7a --- /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|%s|%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..833bbbf --- /dev/null +++ b/tests/file-search.test.js @@ -0,0 +1,150 @@ +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|%s|%n\0'` output. +function listing(rows) { + return rows.map(([type, daysAgo, path, size]) => `${type}|${t(daysAgo)}|${size || 0}|${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|5|/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") +}) + +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 618a8ad..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() } @@ -625,7 +632,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 } @@ -649,9 +656,21 @@ 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 || window.fileView)) return + window.rebuild(window.selectedIndex === window.nextSelectable(-1, 1)) + } } @@ -876,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" @@ -897,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)