Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion Backend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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 || "")) },
Expand Down Expand Up @@ -553,6 +653,7 @@ Item {
if (method === "window.visibilityChanged" && data && data.visible) {
menuSource.refresh()
calculatorData.refresh()
fileIndex.refresh()
}
}

Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<path>`.
- 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).

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
8 changes: 8 additions & 0 deletions bin/omacast
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading