diff --git a/Backend.qml b/Backend.qml index 8895670..7c4008e 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/ScriptCommands.js" as ScriptCommands import "services" Item { @@ -95,6 +96,7 @@ Item { for (var i = 0; i < apps.length; i++) if (apps[i] && apps[i].id) next.push(RootSearch.appCandidate(apps[i])) next = next.concat(RootSearch.menuCandidates(menuSource.tree, menuSource.guards, menuSource.providerItems, backend.deps)) for (var b = 0; b < RootSearch.BUILTINS.length; b++) next.push(RootSearch.builtinCandidate(RootSearch.BUILTINS[b])) + for (var s = 0; s < scriptSource.commands.length; s++) next.push(ScriptCommands.candidate(scriptSource.commands[s])) var byId = ({}) for (var j = 0; j < next.length; j++) byId[next[j].id] = next[j] var entries = FleaSearch.entries(next, Ranker.transliterate) @@ -298,6 +300,19 @@ Item { case "fallback": Util.execArgv(["omarchy-launch-browser", RootSearch.fallbackUrl(candidate, query)]) return { close: true } + case "script": + var script = candidate.script + var args = ScriptCommands.argv(script, (data && data.arguments) || {}) + if (args.error) throw new Error(args.error) + if (script.needsConfirmation && !(data && data.confirmed)) + return { confirm: { title: script.title, message: "Are you sure you want to run this script?", primary: "Run", destructive: false } } + backend.recordVisit(id, query) + scriptRunner.run(script, args.argv) + // silent closes and shows a HUD. shortcut: fullOutput and inline + // run as compact (toast with the last line); add the output view + // and inline refresh when a script needs them. + if (script.mode === "silent") return { close: true } + return { toast: { style: "success", title: "Running " + script.title + "…" } } } throw new Error("Cannot open " + id) } @@ -552,6 +567,7 @@ Item { onNotification: function(method, data) { if (method === "window.visibilityChanged" && data && data.visible) { menuSource.refresh() + scriptSource.refresh() calculatorData.refresh() } } @@ -564,6 +580,20 @@ Item { onFailed: function(message) { backend.log("error", "clipboard", message, null) } } + ScriptCommandSource { + id: scriptSource + directory: backend.directoriesReady ? backend.paths.scriptCommands : "" + onDirectoryChanged: refresh() + onRevisionChanged: rebuildTimer.restart() + onMessage: function(level, text, metadata) { backend.log(level, "script-commands", text, metadata) } + } + + // Launcher.qml shows the result as a toast while the window is open, else a HUD. + ScriptRunner { + id: scriptRunner + onFinished: function(command, result) { backend.emit("scriptCommands.finished", { mode: command.mode, style: result.style, title: result.title }) } + } + AppFocuser { id: appFocuser onLaunchRequested: function(candidate) { backend.launchApp(candidate) } diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..a8969ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,7 @@ 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 ↵. +- **Script Commands** (SPEC §9.9, `core/ScriptCommands.js`): `services/ScriptCommandSource.qml` scans `~/.config/omacast/script-commands` on every open (executable, ≤ 2 MB, no `.template.`, first 60 lines), and `services/ScriptRunner.qml` runs each one under `bash -lc` from its `currentDirectoryPath`. Text, password and dropdown arguments are pills (`←`/`→` cycle a dropdown). `silent` closes and shows a HUD with the last output line; `compact` shows "Running…" then a toast (a HUD if the window closed). `needsConfirmation` uses the alert. Not yet: the `fullOutput` output view and `inline` refresh (both run as `compact`), image icons (emoji only), a settings list of folders, Create/Reload commands. - **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`). diff --git a/Launcher.qml b/Launcher.qml index f14679f..6492237 100644 --- a/Launcher.qml +++ b/Launcher.qml @@ -77,6 +77,17 @@ Item { summon: function(id, payload) { return root.shell ? root.shell.summon(id, payload) : false } } + // A script command's last line of output: a toast if the launcher is still + // open, else a HUD (SPEC §10.2 Toast → HUD). + Connections { + target: backend + function onNotification(method, data) { + if (method !== "scriptCommands.finished") return + if (root.opened && data.mode !== "silent") launcher.showToast(data.style, data.title) + else launcher.hud(data.title) + } + } + LauncherWindow { id: launcher shown: root.opened diff --git a/README.md b/README.md index 5900ced..9fa5d7d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ files: omacast creates its `~/.config`, `~/.local/share`, `~/.cache` and `~/.loc `0700` and its database files as `0600`, and tightens any it finds readable by other users. Text you copy or paste from the calculator goes to `wl-copy` on stdin, never on a command line, so other local users can't read it from the process list. Paste sends Shift+Insert after copying, as Omarchy's clipboard history does. +The one exception is a script command's arguments: they reach your script as `$1`…`$3`, so they are on its +command line while it runs. ### Upgrading from oma-cast @@ -132,6 +134,29 @@ the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` mo | `Esc` / `Shift+Esc` | Back / back to root | | `Ctrl +` / `Ctrl −` / `Ctrl 0` | Zoom | +## Script Commands + +Executable scripts in `~/.config/omacast/script-commands/` become commands, using the same +[Raycast Script Commands](https://github.com/raycast/script-commands) headers: + +```bash +#!/bin/bash +# @raycast.schemaVersion 1 +# @raycast.title Switch Display +# @raycast.mode silent +# @raycast.icon 🖥️ +# @raycast.argument1 { "type": "dropdown", "placeholder": "Input", "data": [{"title": "Mac mini", "value": "x0091"}, {"title": "MacBook Pro", "value": "x00d0"}] } + +monitor-input-cycle "$1" && echo "Switching display" +``` + +- Arguments (`text`, `password`, `dropdown`, up to three) appear as pills after the query: `Tab` moves into them, + and `←`/`→` pick a dropdown option. +- The last line the script prints becomes the result: a HUD for `silent`, a toast for `compact`. +- `fullOutput` and `inline` scripts run as `compact` for now. +- `needsConfirmation` asks first, and `currentDirectoryPath` sets the folder the script runs in. +- The folder is rescanned each time the launcher opens. Scripts need `chmod +x` and a shebang. + ## CLI ```sh @@ -146,7 +171,6 @@ omacast doctor omacast is early. Search, ranking and the Omarchy integration are done. Coming next: - [ ] Settings window -- [ ] Script Commands - [ ] Clipboard history, snippets, quicklinks, calculator - [ ] Running Raycast extensions built from the open-source [`raycast/extensions`](https://github.com/raycast/extensions) repo - [ ] Window management diff --git a/SPEC.md b/SPEC.md index 4e3a823..1cbaef3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -633,6 +633,7 @@ Each running menu-bar command owns one SNI item (ksni): - `silent`: close the window, then a HUD with the last line - `inline`: the first line becomes the root-item subtitle, refreshed every `refreshTime` (≥ 10 s) - Success = exit 0. A compatibility test corpus is the `raycast/script-commands` GitHub repo. +- **Privacy [D, 2026-11-03]:** arguments are the one place user text reaches an argv: `$1..$3` are the Script Commands interface, and the script is the user's own. Everything else still follows the stdin rule. --- diff --git a/core/RootSearch.js b/core/RootSearch.js index b424a03..b1dbcf0 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -359,6 +359,7 @@ function actionsFor(candidate) { case "omarchy-menu": case "omarchy-link": primary = { id: "open", title: "Open Menu", icon: "" }; break case "fallback": primary = { id: "open", title: candidate.title, icon: "" }; break + case "script": primary = { id: "open", title: "Run Script", icon: "\uf120" }; break default: primary = { id: "open", title: "Open Command", icon: "" } } primary.shortcut = "↵" @@ -463,6 +464,7 @@ function aboutFor(c, nowMs) { if (c.kind === "app") add("Desktop Entry", c.appId + ".desktop") if (c.kind === "omarchy-action") add("Command", c.action) if (c.kind === "omarchy-link") add("Opens", c.target) + if (c.kind === "script") { add("Script", c.script.path); add("Mode", c.script.mode) } add("URL", c.url || (c.run && c.run.url)) add("Arguments", (c.arguments || []).map(function(a) { return a.name + (a.required ? "" : " (optional)") }).join(", ")) add("Keywords", (c.keywords || []).join(", ")) diff --git a/core/ScriptCommands.js b/core/ScriptCommands.js new file mode 100644 index 0000000..8c7fef8 --- /dev/null +++ b/core/ScriptCommands.js @@ -0,0 +1,157 @@ +// Script Commands (SPEC §9.9, ref 03 §5) [O]: executables in the script +// commands folder whose comment headers (`# @raycast.title …`) make them +// root-search commands. Pure logic; services/ScriptCommandSource.qml scans +// the folder and Backend.qml runs them. + +var HEADER = /^(?:#|REM|::|\/{2})\s*@raycast\.(\w+)\s+(.*)$/i +var KEYS = ["schemaVersion", "title", "mode", "packageName", "icon", "iconDark", "description", "author", + "authorURL", "currentDirectoryPath", "needsConfirmation", "refreshTime", "argument1", "argument2", "argument3"] +var MODES = { fullOutput: "fullOutput", "full-output": "fullOutput", compact: "compact", silent: "silent", inline: "inline" } +var TERMINAL_GLYPH = "" // nf-fa-terminal + +// One file per record: "\x1e\n" (the scan script's output). +function splitScan(output) { + return String(output || "").split("\x1e").slice(1).map(function(chunk) { + var nl = chunk.indexOf("\n") + return nl < 0 ? { path: chunk, text: "" } : { path: chunk.slice(0, nl), text: chunk.slice(nl + 1) } + }) +} + +function titleCase(name) { + return String(name).replace(/[-_]+/g, " ").replace(/\b\w/g, function(c) { return c.toUpperCase() }) +} + +function dirname(path) { return String(path).replace(/\/[^\/]*$/, "") || "/" } + +// {command} or {error} for one script's text. +function parse(text, path) { + var h = {} + var lower = {} + KEYS.forEach(function(k) { lower[k.toLowerCase()] = k }) + var lines = String(text || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var m = lines[i].trim().match(HEADER) + if (m && lower[m[1].toLowerCase()]) h[lower[m[1].toLowerCase()]] = m[2].trim() + } + if (h.schemaVersion !== "1") return { error: "needs @raycast.schemaVersion 1" } + if (!h.title) return { error: "needs @raycast.title" } + var mode = MODES[h.mode] + if (!mode) return { error: "needs @raycast.mode (fullOutput, compact, silent or inline)" } + if (mode === "inline" && !h.refreshTime) mode = "compact" + + var args = [] + if (mode !== "inline") { + for (var n = 1; n <= 3; n++) { + var raw = h["argument" + n] + if (!raw) continue + var a + try { a = JSON.parse(raw) } catch (e) { return { error: "argument" + n + " is not JSON" } } + var type = a.type || "text" + if (["text", "password", "dropdown"].indexOf(type) < 0) return { error: "argument" + n + " has unknown type " + type } + var options = [] + if (type === "dropdown") { + options = (Array.isArray(a.data) ? a.data : []).filter(function(o) { return o && o.value !== undefined }) + .map(function(o) { return { title: String(o.title || o.value), value: String(o.value) } }) + if (!options.length) return { error: "argument" + n + " dropdown has no data" } + } + args.push({ + name: "argument" + n, type: type, placeholder: String(a.placeholder || "Argument " + n), + required: a.optional !== true, percentEncoded: a.percentEncoded === true, options: options + }) + } + } + + var dir = dirname(path) + return { + command: { + path: path, + title: h.title, + mode: mode, + packageName: h.packageName || titleCase(dir.replace(/^.*\//, "")), + description: h.description || "", + icon: h.icon || "", + // Relative to the script's folder; the runner's `cd` resolves "..". + cwd: (h.currentDirectoryPath || "").charAt(0) === "/" ? h.currentDirectoryPath : dir + "/" + (h.currentDirectoryPath || "."), + needsConfirmation: h.needsConfirmation === "true" && mode !== "inline", + arguments: args + } + } +} + +// Emoji and other short text icons render as a glyph; image paths and URLs +// fall back to the terminal glyph. shortcut: no image icons, add them when +// someone ships scripts with .png icons. +function glyphFor(icon) { + var s = String(icon || "") + return s && s.length <= 8 && !/[\/.]/.test(s) ? s : TERMINAL_GLYPH +} + +function candidate(cmd) { + return { + id: "script:" + cmd.path, + type: "command", + kind: "script", + title: cmd.title, + subtitle: cmd.packageName, + keywords: [cmd.packageName].concat(cmd.description ? [cmd.description] : []), + typeLabel: "Script Command", + glyph: glyphFor(cmd.icon), + glyphFont: "", + arguments: cmd.arguments, + script: cmd, + rootSearch: { when: "always" } + } +} + +// Positional $1..$3 for the run, or {error, index} for the first bad one. +// Empty optional arguments still hold their place as "". +function argv(cmd, values) { + var out = [] + for (var i = 0; i < cmd.arguments.length; i++) { + var def = cmd.arguments[i] + var v = String((values && values[def.name]) || "") + if (def.type !== "password") v = v.trim() + if (!v && def.required) return { error: "Type a " + def.placeholder.toLowerCase() + " first", index: i } + if (v && def.type === "dropdown" && !def.options.some(function(o) { return o.value === v })) + return { error: v + " is not a " + def.placeholder.toLowerCase() + " option", index: i } + out.push(def.percentEncoded ? encodeURIComponent(v) : v) + } + return { argv: out } +} + +function lastLine(output) { + var lines = String(output || "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").split("\n") + for (var i = lines.length - 1; i >= 0; i--) if (lines[i].trim()) return lines[i].trim() + return "" +} + +// The toast or HUD a finished run shows: the last line of its combined +// stdout and stderr (ref 03 §5). +function outcome(cmd, exitCode, output) { + var line = lastLine(output) + if (exitCode === 0) return { style: "success", title: line || cmd.title + " finished" } + return { style: "failure", title: line || cmd.title + " failed (exit " + exitCode + ")" } +} + +// The dropdown option after `value`, `step` places on, wrapping. +function cycleOption(def, value, step) { + var opts = def.options || [] + if (!opts.length) return "" + var at = -1 + for (var i = 0; i < opts.length; i++) if (opts[i].value === value) at = i + if (at < 0) return opts[step > 0 ? 0 : opts.length - 1].value + return opts[(at + step + opts.length) % opts.length].value +} + +function optionTitle(def, value) { + var opts = def.options || [] + for (var i = 0; i < opts.length; i++) if (opts[i].value === value) return opts[i].title + return value +} + +if (typeof module !== "undefined") { + module.exports = { + splitScan: splitScan, parse: parse, candidate: candidate, argv: argv, lastLine: lastLine, + outcome: outcome, cycleOption: cycleOption, optionTitle: optionTitle + } +} diff --git a/services/ScriptCommandSource.qml b/services/ScriptCommandSource.qml new file mode 100644 index 0000000..0c6e139 --- /dev/null +++ b/services/ScriptCommandSource.qml @@ -0,0 +1,45 @@ +import QtQuick +import Quickshell.Io +import "../core/ScriptCommands.js" as ScriptCommands + +// Scans the script commands folder (non-recursive; ref 03 §5 discovery): +// executable regular files, 1 byte to 2 MB, no ".template." in the name. +// Only the first 60 lines are read; headers live at the top. +Item { + id: source + + property string directory: "" + property var commands: [] + property int revision: 0 + + signal message(string level, string text, var metadata) + + function refresh() { + if (!source.directory || scan.running) return + scan.command = ["bash", "-c", source.scanScript, "scan", source.directory] + scan.running = true + } + + readonly property string scanScript: 'for f in "$1"/*; do ' + + '[[ -f $f && -x $f && $f != *.template.* ]] || continue; ' + + 's=$(stat -c %s -- "$f"); (( s > 0 && s <= 2097152 )) || continue; ' + + 'printf "\\036%s\\n" "$f"; head -n 60 -- "$f"; done' + + Process { + id: scan + stdout: StdioCollector { + onStreamFinished: { + var next = [] + var files = ScriptCommands.splitScan(text) + for (var i = 0; i < files.length; i++) { + var r = ScriptCommands.parse(files[i].text, files[i].path) + if (r.command) next.push(r.command) + else source.message("warning", "skipping " + files[i].path + ": " + r.error, null) + } + if (JSON.stringify(next) === JSON.stringify(source.commands)) return + source.commands = next + source.revision++ + } + } + } +} diff --git a/services/ScriptRunner.qml b/services/ScriptRunner.qml new file mode 100644 index 0000000..6a815a7 --- /dev/null +++ b/services/ScriptRunner.qml @@ -0,0 +1,40 @@ +import QtQuick +import Quickshell.Io +import "../core/ScriptCommands.js" as ScriptCommands + +// Runs one script command per Process: the file itself (it needs +x and a +// shebang), from its currentDirectoryPath, under a login shell for the +// user's PATH (ref 03 §5 "Execution"). stdin is closed, as in Raycast. +Item { + id: runner + + signal finished(var command, var result) + + // Arguments reach the script as $1..$3, so they are in its argv: that is + // the Script Commands interface, and the script is the user's own [D]. + function run(command, args) { + var proc = processComponent.createObject(runner, { + script: command, + command: ["bash", "-lc", 'cd -- "$1" || exit; shift; exec "$@" &1', "script", command.cwd, command.path].concat(args) + }) + proc.running = true + } + + Component { + id: processComponent + Process { + id: proc + property var script: null + // Exit and end of output arrive in either order; report after both. + property var exitCode: null + property var output: null + function report() { + if (proc.exitCode === null || proc.output === null) return + runner.finished(proc.script, ScriptCommands.outcome(proc.script, proc.exitCode, proc.output)) + proc.destroy() + } + stdout: StdioCollector { onStreamFinished: { proc.output = text; proc.report() } } + onExited: function(code) { proc.exitCode = code; proc.report() } + } + } +} diff --git a/tests/script-commands.test.js b/tests/script-commands.test.js new file mode 100644 index 0000000..7c6473a --- /dev/null +++ b/tests/script-commands.test.js @@ -0,0 +1,102 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const os = require("node:os") +const path = require("node:path") +const { spawnSync } = require("node:child_process") +const SC = require("../core/ScriptCommands.js") + +const root = path.join(__dirname, "..") + +const DISPLAY = [ + "#!/bin/bash", + "# @raycast.schemaVersion 1", + "# @raycast.title Switch Display", + "# @raycast.mode silent", + "# @raycast.icon 🖥️", + '# @raycast.argument1 { "type": "dropdown", "placeholder": "Input", "data": [{"title": "Mac mini", "value": "x0091"}, {"title": "MacBook Pro", "value": "x00d0"}] }', + '# @raycast.argument2 { "type": "text", "placeholder": "Note", "optional": true, "percentEncoded": true }', + 'echo "$1"' +].join("\n") + +test("headers parse into a command with its arguments", () => { + const { command } = SC.parse(DISPLAY, "/home/u/.config/omacast/script-commands/display.sh") + assert.equal(command.title, "Switch Display") + assert.equal(command.mode, "silent") + assert.equal(command.packageName, "Script Commands", "defaults to the folder name, title-cased") + assert.equal(command.cwd, "/home/u/.config/omacast/script-commands/.") + assert.deepEqual(command.arguments.map((a) => [a.name, a.type, a.required]), [["argument1", "dropdown", true], ["argument2", "text", false]]) + assert.deepEqual(command.arguments[0].options[0], { title: "Mac mini", value: "x0091" }) + const c = SC.candidate(command) + assert.equal(c.kind, "script") + assert.equal(c.glyph, "🖥️") +}) + +test("other comment styles, key case and modes", () => { + const js = "// @raycast.schemaVersion 1\n// @raycast.Title Hi\n// @raycast.mode full-output\n// @raycast.currentDirectoryPath ../x\n// @raycast.needsConfirmation true" + const { command } = SC.parse(js, "/a/b/hi.js") + assert.equal(command.title, "Hi") + assert.equal(command.mode, "fullOutput") + assert.equal(command.cwd, "/a/b/../x") + assert.equal(command.needsConfirmation, true) + assert.equal(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode inline", "/a/t").command.mode, "compact", "inline without refreshTime") +}) + +test("invalid headers are reported", () => { + assert.match(SC.parse("# @raycast.title T\n# @raycast.mode silent", "/a").error, /schemaVersion/) + assert.match(SC.parse("# @raycast.schemaVersion 1\n# @raycast.mode silent", "/a").error, /title/) + assert.match(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode loud", "/a").error, /mode/) + assert.match(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode silent\n# @raycast.argument1 {nope", "/a").error, /JSON/) +}) + +test("argv validates required arguments and dropdown values, and percent-encodes", () => { + const { command } = SC.parse(DISPLAY, "/s/display.sh") + assert.equal(SC.argv(command, {}).index, 0) + assert.match(SC.argv(command, { argument1: "x9999" }).error, /not a input option/) + assert.deepEqual(SC.argv(command, { argument1: "x00d0" }).argv, ["x00d0", ""]) + assert.deepEqual(SC.argv(command, { argument1: "x00d0", argument2: "a b" }).argv, ["x00d0", "a%20b"]) +}) + +test("dropdown cycling wraps both ways", () => { + const def = SC.parse(DISPLAY, "/s/d").command.arguments[0] + assert.equal(SC.cycleOption(def, "", 1), "x0091") + assert.equal(SC.cycleOption(def, "", -1), "x00d0") + assert.equal(SC.cycleOption(def, "x00d0", 1), "x0091") + assert.equal(SC.optionTitle(def, "x00d0"), "MacBook Pro") +}) + +test("the result is the last non-empty line, ANSI stripped", () => { + const cmd = { title: "T" } + assert.deepEqual(SC.outcome(cmd, 0, "one\n\x1b[32mDone\x1b[0m\n\n"), { style: "success", title: "Done" }) + assert.deepEqual(SC.outcome(cmd, 2, "oops\n"), { style: "failure", title: "oops" }) + assert.deepEqual(SC.outcome(cmd, 1, ""), { style: "failure", title: "T failed (exit 1)" }) +}) + +test("the scan finds executable scripts only and keeps their headers", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-scripts-")) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(dir + "/display.sh", DISPLAY, { mode: 0o755 }) + fs.writeFileSync(dir + "/plain.sh", DISPLAY, { mode: 0o644 }) + fs.writeFileSync(dir + "/x.template.sh", DISPLAY, { mode: 0o755 }) + fs.writeFileSync(dir + "/empty.sh", "", { mode: 0o755 }) + fs.mkdirSync(dir + "/sub") + const qml = fs.readFileSync(path.join(root, "services/ScriptCommandSource.qml"), "utf8") + const script = eval(qml.match(/scanScript: ('[\s\S]*?')\n\n/)[1]) + const r = spawnSync("bash", ["-c", script, "scan", dir], { encoding: "utf8" }) + assert.equal(r.status, 0, r.stderr) + const files = SC.splitScan(r.stdout) + assert.deepEqual(files.map((f) => path.basename(f.path)), ["display.sh"]) + assert.equal(SC.parse(files[0].text, files[0].path).command.title, "Switch Display") +}) + +test("the runner runs the script from its folder with $1..$3", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-run-")) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.mkdirSync(dir + "/bin") + fs.writeFileSync(dir + "/bin/s.sh", '#!/bin/bash\necho "$PWD|$1|$2|$#"; echo err >&2\n', { mode: 0o755 }) + const qml = fs.readFileSync(path.join(root, "services/ScriptRunner.qml"), "utf8") + const wrapper = qml.match(/"bash", "-lc", '([^']*)'/)[1] + const cwd = SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode silent\n# @raycast.currentDirectoryPath ..", dir + "/bin/s.sh").command.cwd + const r = spawnSync("bash", ["-c", wrapper, "script", cwd, dir + "/bin/s.sh", "x00d0", ""], { encoding: "utf8" }) + assert.equal(r.stdout, dir + "|x00d0||2\nerr\n", "runs from the resolved folder; stderr joins stdout") +}) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..ecef4ec 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -6,6 +6,7 @@ import qs.Commons as Commons import qs.Ui import "../core/RootSearch.js" as RootSearch import "../core/Rows.js" as Rows +import "../core/ScriptCommands.js" as ScriptCommands // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen // overlay layer the shell's own emoji picker uses. @@ -332,6 +333,9 @@ PanelWindow { return } if (outcome.toast) window.showToast(outcome.toast.style, outcome.toast.title, outcome.toast.actions) + // A command that ran keeps the window open; a second ↵ starts it fresh. + window.argValues = ({}) + window.argFocus = -1 window.rebuild(false) } @@ -425,6 +429,14 @@ PanelWindow { return true } if (key === Qt.Key_Backspace && !value) { window.argFocus--; return true } + // A dropdown pill (script commands) steps through its options; typing + // doesn't edit it. + if (def.type === "dropdown") { + if (key === Qt.Key_Right || key === Qt.Key_Down) { set(ScriptCommands.cycleOption(def, value, 1)); return true } + if (key === Qt.Key_Left || key === Qt.Key_Up) { set(ScriptCommands.cycleOption(def, value, -1)); return true } + if (key === Qt.Key_Backspace) { set(""); return true } + return window.isTyping(event) + } if (Util.editsFilter(event, value)) { set(Util.editedFilter(event, value)); return true } if (window.isTyping(event)) { set(value + event.text); return true } return false @@ -476,6 +488,7 @@ PanelWindow { if (!text) return if (window.actionPanelOpen) actionPanel.item.filter += text else if (window.argFocus >= 0) { + if (window.argDefs[window.argFocus].type === "dropdown") return var name = window.argDefs[window.argFocus].name var values = Object.assign({}, window.argValues) values[name] = (values[name] || "") + text @@ -784,7 +797,12 @@ PanelWindow { anchors.rightMargin: window.px(10) anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText - text: pill.value || (pill.modelData.placeholder + (pill.modelData.required ? "" : " (optional)")) + readonly property bool dropdown: pill.modelData.type === "dropdown" + readonly property string shownValue: dropdown ? ScriptCommands.optionTitle(pill.modelData, pill.value) + : pill.modelData.type === "password" ? "•".repeat(pill.value.length) : pill.value + text: (dropdown && pill.focused ? "‹ " : "") + + (shownValue || (pill.modelData.placeholder + (pill.modelData.required ? "" : " (optional)"))) + + (dropdown && pill.focused ? " ›" : "") opacity: pill.value ? 1 : 0.5 color: window.look.foreground elide: Text.ElideLeft @@ -793,7 +811,7 @@ PanelWindow { } Rectangle { - visible: pill.focused && window.shown + visible: pill.focused && window.shown && !pillText.dropdown width: Math.max(1, Style.space(2)) height: pillText.font.pixelSize + Style.space(2) anchors.verticalCenter: parent.verticalCenter @@ -804,7 +822,15 @@ PanelWindow { MouseArea { anchors.fill: parent cursorShape: Qt.IBeamCursor - onClicked: { window.argFocus = pill.index; keyCatcher.forceActiveFocus() } + onClicked: { + if (pill.modelData.type === "dropdown" && pill.focused) { + var values = Object.assign({}, window.argValues) + values[pill.modelData.name] = ScriptCommands.cycleOption(pill.modelData, pill.value, 1) + window.argValues = values + } + window.argFocus = pill.index + keyCatcher.forceActiveFocus() + } } } }