Add Shell Command plugin for launcher commands (#290)
* Add Quick Shell plugin for launcher commands Introduces `/qs` launcher prefix to run shell commands in an interactive terminal. Features dynamic shell completions (Fish/Bash), command history, user snippets, and directory navigation. * Rename quick-shell plugin to shell command plugin * Update thumbnail.webp * Document shell dependencies and filter completions by current word Filter bash compgen completions to the current word being typed. Declare `sh`, `ls`, `fish`, and `bash` as plugin dependencies.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
# Shell Command
|
||||
|
||||
Run a shell command straight from the Noctalia launcher. Type `/sh` followed by
|
||||
any command and press Enter to open it in your default terminal — a **real,
|
||||
interactive shell** with live output, TUI apps, and your own native history.
|
||||
|
||||
No hardcoded completion table: suggestions come from the shell's own completion
|
||||
engine and your command history, so they stay in sync with what's actually on
|
||||
your system. Commands run through your own `$SHELL` in interactive mode, so
|
||||
aliases, functions and environment from your rc config are available.
|
||||
|
||||
## Features
|
||||
|
||||
- **Instant command run** — `/sh ls -la ~/projects` opens the command in your
|
||||
default terminal.
|
||||
- **Fish-style autosuggestions** — completions fetched live from Fish's
|
||||
completion engine (`fish -c 'complete -C "<query>"'`), falling back to bash
|
||||
`compgen -c` when Fish isn't installed. Type `/sh git st` and get `git status`,
|
||||
`git stash`, etc. Suggestions dynamically follow your system — no hardcoded
|
||||
list to maintain.
|
||||
- **Snap-complete** — when a prefix has exactly one completion, the "Run" entry
|
||||
jumps to the completed command, so one Enter runs it instead of fill-then-run.
|
||||
- **History** — previously run commands are remembered (per-plugin state, capped
|
||||
at 100) and offered as you type, most recent first.
|
||||
- **Snippets** — user-defined commands shown when `/sh` is typed with an empty
|
||||
query.
|
||||
- **Folder jump** — `/sh cd` lists subdirectories and lets you drill into nested
|
||||
folders. Select the "Open in:" row to launch a terminal inside the current
|
||||
directory.
|
||||
- **Navigate and run in one launch** — `/sh cd ~/proj && make` changes into that
|
||||
directory and runs the command, so tools open in the right folder.
|
||||
- **Suggestion fills, explicit launch runs** — completion/history/snippet rows
|
||||
fill the input (so you can keep typing or drill deeper); only "Run:" and
|
||||
"Open in:" rows actually launch a terminal.
|
||||
- **Stay-open terminal** — after a fast command (e.g. `git status`) the terminal
|
||||
holds its output, shows a `[Press Enter to continue]` prompt, then drops you
|
||||
into an interactive shell.
|
||||
- **Workspace-aware** — when a default workspace is set, commands start in that
|
||||
directory.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------- |
|
||||
| ID | `weinguyen/shell-command` |
|
||||
| Entry | Launcher provider: `provider` |
|
||||
| Launcher Prefix | `/sh` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Noctalia v5.0.0 or higher.
|
||||
- `runInTerminal` needs a default terminal configured in Noctalia.
|
||||
- A shell at `$SHELL` (falls back to `sh`).
|
||||
- Optional: Fish (richer completions; falls back to bash otherwise).
|
||||
- `ls` for the folder-jump listing.
|
||||
|
||||
Declared in `plugin.toml`
|
||||
`dependencies`: `sh`, `ls`, plus `fish` and `bash` for the completion fallback
|
||||
(the user's own `$SHELL` at runtime is whatever shell they have configured).
|
||||
|
||||
## Usage
|
||||
|
||||
Open the launcher and type `/sh` followed by a command:
|
||||
|
||||
```
|
||||
/sh
|
||||
/sh ls -la ~/projects
|
||||
/sh git status
|
||||
```
|
||||
|
||||
Press Enter to run the command in your default terminal.
|
||||
|
||||
With an empty query, recent commands and configured snippets are offered. As you
|
||||
type a command, suggestions from the shell's completion engine appear under the
|
||||
exact command you typed. Selecting a suggestion fills the input; run the filled
|
||||
command by pressing Enter again (or use snap-complete when only one completion
|
||||
matches).
|
||||
|
||||
### Navigate inside a folder first
|
||||
|
||||
```
|
||||
/sh cd # list top-level folders
|
||||
/sh cd proj # list folders starting with "proj"
|
||||
/sh cd ~/Builds/ # list everything directly inside ~/Builds
|
||||
```
|
||||
|
||||
Folder rows let you drill deeper (each selection fills the path with a trailing
|
||||
slash so you can keep going). The top "Open in:" row launches a terminal inside
|
||||
the current parent directory.
|
||||
|
||||
### Run a command in a directory
|
||||
|
||||
```
|
||||
/sh cd ~/Builds && make
|
||||
```
|
||||
|
||||
Navigates into `~/Builds` and runs `make` there. Paths may contain spaces,
|
||||
quotes and `\ ` escapes.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| ------------------- | ------------- | ------- | ------------------------------------------------------------------------------- |
|
||||
| `default_workspace` | `folder` | `""` | Working directory commands (and the `cd` listing) start in. Empty uses `$HOME`. |
|
||||
| `snippets` | `string_list` | `[]` | Commands shown when `/sh` is typed with an empty query. |
|
||||
|
||||
## Notes
|
||||
|
||||
- Commands run in a real interactive terminal, so tools like `vim`, `htop` and
|
||||
`tmux` work normally.
|
||||
- Commands execute via `$SHELL -ic`, so aliases and functions from your rc
|
||||
config are available. Note that completion — not alias expansion — is what
|
||||
powers suggestions; an alias defined only transitively may still need its
|
||||
underlying command.
|
||||
- Completion suggestions update dynamically as Fish completions and installed
|
||||
binaries change — there is no hardcoded list to maintain.
|
||||
- History is stored per-plugin (XDG state directory), capped at 100 entries,
|
||||
deduplicated, most recent command first.
|
||||
|
||||
## Development
|
||||
|
||||
- `shell_provider.luau` — the launcher provider entry.
|
||||
- `translations/en.json`, `translations/vi.json` — user-facing strings.
|
||||
@@ -0,0 +1,32 @@
|
||||
id = "weinguyen/shell-command"
|
||||
name = "Shell Command"
|
||||
version = "0.1.0"
|
||||
plugin_api = 3
|
||||
author = "weinguyen"
|
||||
license = "MIT"
|
||||
icon = "terminal"
|
||||
description = "Run a shell command from the launcher. Type /sh then command open it your default terminal."
|
||||
tags = ["launcher", "productivity", "development"]
|
||||
dependencies = ["bash", "fish", "ls", "sh"]
|
||||
|
||||
[[setting]]
|
||||
key = "default_workspace"
|
||||
type = "folder"
|
||||
default = ""
|
||||
label_key = "settings.default_workspace.label"
|
||||
description_key = "settings.default_workspace.description"
|
||||
|
||||
[[setting]]
|
||||
key = "snippets"
|
||||
type = "string_list"
|
||||
default = []
|
||||
label_key = "settings.snippets.label"
|
||||
description_key = "settings.snippets.description"
|
||||
|
||||
[[launcher_provider]]
|
||||
id = "provider"
|
||||
entry = "shell_provider.luau"
|
||||
prefix = "sh"
|
||||
glyph = "terminal"
|
||||
include_in_global_search = false
|
||||
debounce_ms = 150
|
||||
@@ -0,0 +1,482 @@
|
||||
-- Shell Command launcher provider.
|
||||
--
|
||||
-- Type `/sh <command>` in the launcher to run a shell command in your default
|
||||
-- terminal. Fish-style autosuggestions: as you type it queries the shell's own
|
||||
-- completion engine (`fish -c 'complete -C "<query>"'`, falling back to bash
|
||||
-- `compgen -c`) so suggestions grow dynamically with your system — no hardcoded
|
||||
-- list. Commands from your own history and user-defined snippets are offered
|
||||
-- too. A `cd` mode lists folders so you can open a terminal in a chosen
|
||||
-- directory. Selecting any entry opens it in a real terminal (via
|
||||
-- noctalia.runInTerminal), so you get a full shell with live output, TUI apps,
|
||||
-- and native history.
|
||||
|
||||
local HISTORY_FILE = "history"
|
||||
local HISTORY_LIMIT = 100
|
||||
local MAX_SUGGESTIONS = 8
|
||||
|
||||
local history = {}
|
||||
|
||||
local function shellQuote(s)
|
||||
return "'" .. s:gsub("'", "'\"'\"'") .. "'"
|
||||
end
|
||||
|
||||
local function trim(s)
|
||||
return noctalia.string.trim(tostring(s or ""))
|
||||
end
|
||||
|
||||
-- Private per-plugin storage (XDG state); the host creates the directory on
|
||||
-- every call. nil (with a log line) when no state directory resolves.
|
||||
local function dataDir()
|
||||
local dir, err = noctalia.pluginDataDir()
|
||||
if dir == nil then
|
||||
noctalia.log("shell-command: pluginDataDir failed: " .. tostring(err))
|
||||
return nil
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
local function loadHistory()
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
return
|
||||
end
|
||||
local raw = noctalia.readFile(dir .. "/" .. HISTORY_FILE)
|
||||
if type(raw) ~= "string" or raw == "" then
|
||||
return
|
||||
end
|
||||
history = {}
|
||||
for line in raw:gmatch("[^\r\n]+") do
|
||||
line = trim(line)
|
||||
if line ~= "" then
|
||||
history[#history + 1] = line
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function saveHistory()
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
return
|
||||
end
|
||||
local ok, err = noctalia.writeFile(dir .. "/" .. HISTORY_FILE, table.concat(history, "\n"))
|
||||
if not ok then
|
||||
noctalia.log("shell-command: could not persist history: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- Record a run command: move it to the front (most recent), dedupe, cap size.
|
||||
local function recordHistory(cmd)
|
||||
for i, entry in ipairs(history) do
|
||||
if entry == cmd then
|
||||
table.remove(history, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
table.insert(history, 1, cmd)
|
||||
if #history > HISTORY_LIMIT then
|
||||
for _ = HISTORY_LIMIT + 1, #history do
|
||||
table.remove(history)
|
||||
end
|
||||
end
|
||||
saveHistory()
|
||||
end
|
||||
|
||||
-- Build the command actually launched in the terminal.
|
||||
--
|
||||
-- If the query itself is a `cd <dir> && <cmd>` compound, we cd into `<dir>`
|
||||
-- instead of the workspace — that way navigation and a command can be combined
|
||||
-- in one launch (`/sh cd ~/proj && ls`). Otherwise, if a default workspace is
|
||||
-- configured, cd there first.
|
||||
--
|
||||
-- Commands run through the user's own `$SHELL` (not `sh`), so aliases,
|
||||
-- shell functions and environment from the login shell are available. The
|
||||
-- command is wrapped so the terminal stays open after it finishes (a fast
|
||||
-- command like `git status` would otherwise close the window before you can
|
||||
-- read the output). We hold the output on screen with `read`, then drop into a
|
||||
-- fresh interactive shell so the user can keep typing — exec'ing the shell
|
||||
-- directly clears the result before it can be read.
|
||||
-- Wrap a command in the terminal-held-open wrapper and run it via the user's
|
||||
-- own interactive shell (`$SHELL -ic`) so aliases and functions from the
|
||||
-- login/rc config are available — a bare `-c` ignores them and breaks things
|
||||
-- like fish aliases. After the command we `exec $SHELL` into a fresh
|
||||
-- interactive shell.
|
||||
local function buildLaunch(inner)
|
||||
local shell = noctalia.getenv("SHELL") or "sh"
|
||||
-- `-ic`: user's rc config/aliases load. Alias-safe `read` (fish shows an
|
||||
-- ugly `read>` prompt interactively — use the silent `-s`/`-l` variant; bash
|
||||
-- and zsh `read` are silent already and have no such prompt) holds the
|
||||
-- output until Enter, then we exec a fresh interactive shell that stays open.
|
||||
local base = shell:match("([^/]+)$") or "sh"
|
||||
local readCmd = (base == "fish") and "read -l -s __x" or "read __x"
|
||||
local printfPrompt = "printf '%s\\n' '[Press Enter to continue] [Ctrl+C to Close]'"
|
||||
return shell .. " -ic " .. shellQuote(inner .. "; " .. printfPrompt .. "; " .. readCmd .. "; exec $SHELL")
|
||||
end
|
||||
|
||||
local function buildCommand(cmd)
|
||||
local inner = cmd
|
||||
-- Try to split a leading `cd <path> && <rest>` compound, honouring quoted
|
||||
-- paths, spaces and `\ ` escapes inside the directory argument.
|
||||
local dir = cmd:match("^%s*cd%s+(.+)$")
|
||||
if dir then
|
||||
local i, n = 1, #dir
|
||||
local out = {}
|
||||
local quote
|
||||
local done
|
||||
while i <= n do
|
||||
local c = dir:sub(i, i)
|
||||
if quote then
|
||||
if c == quote then
|
||||
quote = nil
|
||||
elseif c == "\\" and i < n then
|
||||
out[#out + 1] = dir:sub(i + 1, i + 1)
|
||||
i = i + 1
|
||||
else
|
||||
out[#out + 1] = c
|
||||
end
|
||||
elseif c == '"' or c == "'" then
|
||||
quote = c
|
||||
elseif c == " " then
|
||||
done = true
|
||||
break
|
||||
else
|
||||
out[#out + 1] = c
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
if not done then
|
||||
i = n + 1 -- path ran to end; nothing appended
|
||||
end
|
||||
local rest = dir:sub(i):match("^%s*&&%s*(.+)$")
|
||||
if #out > 0 and rest then
|
||||
local path = table.concat(out)
|
||||
local abs = path:gsub("^~/+", (noctalia.getenv("HOME") or "/") .. "/")
|
||||
inner = "cd " .. shellQuote(abs) .. " && " .. rest
|
||||
else
|
||||
inner = cmd
|
||||
end
|
||||
else
|
||||
local cwd = noctalia.getConfig("default_workspace")
|
||||
if type(cwd) == "string" and cwd ~= "" then
|
||||
inner = "cd " .. shellQuote(cwd) .. " && " .. cmd
|
||||
end
|
||||
end
|
||||
-- Run through the user's own interactive shell (`-ic`) so aliases and
|
||||
-- functions from the login/rc config are available, not just `-c` which
|
||||
-- ignores them. The wrapper is a real terminal so it's interactive-stable;
|
||||
-- after the command we `exec $SHELL` into a fresh interactive shell.
|
||||
return buildLaunch(inner)
|
||||
end
|
||||
|
||||
-- Open a terminal directly inside a chosen directory (no command to run).
|
||||
local function buildCdCommand(path)
|
||||
-- Also interactive so aliases work inside the opened shell.
|
||||
return buildLaunch("cd " .. shellQuote(path))
|
||||
end
|
||||
|
||||
-- The first token of the command, used to detect whether the binary exists.
|
||||
local function firstToken(cmd)
|
||||
local token = cmd:match("^%s*([^%s]+)")
|
||||
return token or ""
|
||||
end
|
||||
|
||||
local function runEntry(cmd, subtitle)
|
||||
return {
|
||||
id = "run:" .. cmd,
|
||||
title = noctalia.tr("run_title", { command = cmd }),
|
||||
subtitle = subtitle,
|
||||
glyph = "terminal",
|
||||
}
|
||||
end
|
||||
|
||||
-- Rebuild the full command from a completion token. The completion engine
|
||||
-- returns only the token being completed, so we re-attach the typed prefix.
|
||||
local function fullCommand(query, comp)
|
||||
local prefix = query:match("^(.*%s)") or ""
|
||||
return prefix .. comp
|
||||
end
|
||||
|
||||
-- Parse `fish -c 'complete -C "<query>"'` output: one completion per line,
|
||||
-- optionally `completion<TAB>description`. We keep the completion token.
|
||||
local function parseFish(output)
|
||||
local comps = {}
|
||||
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
|
||||
line = trim(line)
|
||||
if line ~= "" then
|
||||
local comp = line:match("^([^\t]+)")
|
||||
if comp and comp ~= "" then
|
||||
comps[#comps + 1] = comp
|
||||
end
|
||||
end
|
||||
end
|
||||
return comps
|
||||
end
|
||||
|
||||
-- Parse `compgen -c` output: one command name per line.
|
||||
local function parseCompgen(output)
|
||||
local comps = {}
|
||||
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
|
||||
line = trim(line)
|
||||
if line ~= "" then
|
||||
comps[#comps + 1] = line
|
||||
end
|
||||
end
|
||||
return comps
|
||||
end
|
||||
|
||||
-- Query the shell's completion engine for the typed prefix. Prefers fish
|
||||
-- (full subcommand/flag completions); falls back to bash `compgen -c` for
|
||||
-- command names when fish is unavailable.
|
||||
local function fetchCompletions(query, callback)
|
||||
if noctalia.commandExists("fish") then
|
||||
noctalia.runAsync("fish -c " .. shellQuote("complete -C " .. shellQuote(query)), function(result)
|
||||
callback(parseFish(result.stdout))
|
||||
end)
|
||||
return
|
||||
end
|
||||
if noctalia.commandExists("bash") then
|
||||
local word = query:match("([^%s]+)%s*$") or ""
|
||||
noctalia.runAsync("bash -c " .. shellQuote("compgen -c " .. shellQuote(word)), function(result)
|
||||
callback(parseCompgen(result.stdout))
|
||||
end)
|
||||
return
|
||||
end
|
||||
callback({})
|
||||
end
|
||||
|
||||
-- List subdirectories of the cd root (default workspace, else $HOME) so the
|
||||
-- user can open a terminal inside a chosen folder. Uses `ls -d */` (fast, one
|
||||
-- level) rather than `find`, which can crawl slowly over a large home dir.
|
||||
-- Expand leading ~ and join a relative path onto the workspace base so
|
||||
-- breadcrumb navigation works from any spelling: `~/Builds`, `Builds`,
|
||||
-- `/abs/path`. Trailing slash preserved. Returns an absolute path.
|
||||
local function resolvePath(p)
|
||||
local base = noctalia.getConfig("default_workspace")
|
||||
if type(base) ~= "string" or base == "" then
|
||||
base = noctalia.getenv("HOME") or "/"
|
||||
end
|
||||
if type(p) ~= "string" or (p:match("^%s*$")) then
|
||||
return base
|
||||
end
|
||||
p = p:gsub("^~/+", (noctalia.getenv("HOME") or "/") .. "/")
|
||||
if p:sub(1, 1) ~= "/" then
|
||||
-- Relative (or empty) — resolve against workspace base.
|
||||
p = base .. "/" .. p:gsub("^/+", "")
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
-- List subdirectories to navigate into, breadcrumb-style. The query after
|
||||
-- `cd` is a path you're halfway through typing: the parent directory is listed
|
||||
-- and the typed prefix filters it. Selecting a folder fills the input with that
|
||||
-- path (cd:<path>/), letting you drill deeper or continue typing.
|
||||
--
|
||||
-- /sh cd -> list top-level dirs of the workspace
|
||||
-- /sh cd proj -> list workspace dirs starting with "proj"
|
||||
-- /sh cd ~/Builds -> list ~/Builds sub-entries starting with the rest
|
||||
-- /sh cd ~/Builds/ -> list everything directly inside ~/Builds
|
||||
local function listDirs(query)
|
||||
local pathPart = query:match("^cd%s*(.*)$") or ""
|
||||
|
||||
local parent, prefix
|
||||
if pathPart == "" then
|
||||
-- Root of the workspace, and nothing typed to drill before it.
|
||||
parent, prefix = resolvePath(""), ""
|
||||
elseif pathPart:match("[/]$") then
|
||||
-- Ends with a slash: navigate into an existing directory.
|
||||
parent, prefix = resolvePath(pathPart), ""
|
||||
else
|
||||
local slash = pathPart:match("^.*/")
|
||||
if slash then
|
||||
-- Mid-path: split at the last slash into parent + typed prefix.
|
||||
local base2 = resolvePath(slash:gsub("/$", ""))
|
||||
parent = base2
|
||||
prefix = pathPart:sub(#slash + 1):gsub("^%s", "")
|
||||
else
|
||||
-- Bare first segment — resolve against base, prefix is the whole pathPart.
|
||||
parent, prefix = resolvePath(""), pathPart
|
||||
end
|
||||
end
|
||||
|
||||
local dirs = {}
|
||||
local names = noctalia.listDir(parent) or {}
|
||||
table.sort(names)
|
||||
for _, name in ipairs(names) do
|
||||
if name ~= "." and name ~= ".." and (prefix == "" or name:sub(1, #prefix) == prefix) then
|
||||
local info = noctalia.fileInfo(parent .. "/" .. name)
|
||||
if info and info.isDir then
|
||||
dirs[#dirs + 1] = { path = parent .. "/" .. name, name = name }
|
||||
end
|
||||
end
|
||||
end
|
||||
return dirs, parent
|
||||
end
|
||||
|
||||
local pendingQuery = nil
|
||||
|
||||
function onQuery(query)
|
||||
query = trim(query)
|
||||
pendingQuery = query
|
||||
|
||||
if query == "" then
|
||||
-- Empty query: hint, then user snippets, then recent commands.
|
||||
local results = {
|
||||
{
|
||||
id = "",
|
||||
title = noctalia.tr("hint_title"),
|
||||
subtitle = noctalia.tr("hint_subtitle"),
|
||||
glyph = "terminal",
|
||||
},
|
||||
}
|
||||
local snippets = noctalia.getConfig("snippets")
|
||||
if type(snippets) == "table" then
|
||||
for _, snippet in ipairs(snippets) do
|
||||
snippet = trim(snippet)
|
||||
if snippet ~= "" then
|
||||
results[#results + 1] = {
|
||||
id = "fill:" .. snippet,
|
||||
title = snippet,
|
||||
subtitle = noctalia.tr("snippet_subtitle"),
|
||||
glyph = "bookmark",
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
for i = 1, math.min(MAX_SUGGESTIONS, #history) do
|
||||
results[#results + 1] = {
|
||||
id = "fill:" .. history[i],
|
||||
title = history[i],
|
||||
subtitle = noctalia.tr("history_subtitle"),
|
||||
glyph = "history",
|
||||
}
|
||||
end
|
||||
launcher.setResults(query, results)
|
||||
return
|
||||
end
|
||||
|
||||
-- cd mode: breadcrumb folder navigation. Folder rows fill input with
|
||||
-- trailing-slash path (`fill:`) so Enter drills one level deeper and can
|
||||
-- keep going. First "Open in:" row launches terminal directly in the
|
||||
-- current parent dir (`cdgo:`).
|
||||
-- A `cd <path> && <cmd>` compound launches a real command (navigation +
|
||||
-- execution), not folder navigation. Skip the cd branch so it reaches the
|
||||
-- run path, where buildCommand parses and executes it.
|
||||
local isCompound = query:match("^%s*cd%s+.+&&.+$")
|
||||
|
||||
if not isCompound and (query == "cd" or query:match("^cd%s")) then
|
||||
local dirs, parent = listDirs(query)
|
||||
local results = {}
|
||||
results[#results + 1] = {
|
||||
id = "cdgo:" .. parent,
|
||||
title = noctalia.tr("cd_open_title", { path = parent }),
|
||||
subtitle = noctalia.tr("cd_open_subtitle"),
|
||||
glyph = "folder-open",
|
||||
}
|
||||
for _, dir in ipairs(dirs) do
|
||||
results[#results + 1] = {
|
||||
id = "fill:cd " .. dir.path .. "/",
|
||||
title = dir.name,
|
||||
subtitle = dir.path,
|
||||
glyph = "folder",
|
||||
}
|
||||
end
|
||||
if #dirs == 0 then
|
||||
results[#results + 1] = {
|
||||
id = "",
|
||||
title = noctalia.tr("cd_empty"),
|
||||
subtitle = noctalia.tr("cd_empty_subtitle"),
|
||||
glyph = "folder",
|
||||
}
|
||||
end
|
||||
launcher.setResults(query, results)
|
||||
return
|
||||
end
|
||||
|
||||
local token = firstToken(query)
|
||||
local subtitle = noctalia.tr("run_subtitle")
|
||||
if token ~= "" and noctalia.commandExists(token) then
|
||||
subtitle = noctalia.tr("run_subtitle_found", { command = token })
|
||||
end
|
||||
|
||||
-- Show the exact command immediately, then fill in suggestions async.
|
||||
launcher.setResults(query, { runEntry(query, subtitle) })
|
||||
|
||||
fetchCompletions(query, function(comps)
|
||||
if pendingQuery ~= query then
|
||||
return -- a newer query superseded this one
|
||||
end
|
||||
local results = { runEntry(query, subtitle) }
|
||||
-- Snap-complete: a single distinct expanding candidate (a completion that
|
||||
-- actually extends the typed text) becomes the one Enter action — run the
|
||||
-- completed command directly without first picking a fill row. Multiple
|
||||
-- candidates: keep normal suggestions. Uses its own seen-table so the
|
||||
-- suggestion pass below still emits the full list.
|
||||
local seenScan, single, count = { [query] = true }, nil, 0
|
||||
for _, comp in ipairs(comps) do
|
||||
local full = fullCommand(query, comp)
|
||||
if full ~= query and not seenScan[full] then
|
||||
seenScan[full] = true
|
||||
single, count = full, count + 1
|
||||
end
|
||||
end
|
||||
if count == 1 and single then
|
||||
results = { runEntry(single, noctalia.tr("snap_subtitle", { command = single })) }
|
||||
end
|
||||
local seen = { [query] = true }
|
||||
for _, comp in ipairs(comps) do
|
||||
local full = fullCommand(query, comp)
|
||||
if not seen[full] then
|
||||
seen[full] = true
|
||||
results[#results + 1] = {
|
||||
id = "fill:" .. full,
|
||||
title = full,
|
||||
subtitle = noctalia.tr("suggest_subtitle"),
|
||||
glyph = "lightbulb",
|
||||
}
|
||||
end
|
||||
if #results >= 1 + MAX_SUGGESTIONS then
|
||||
break
|
||||
end
|
||||
end
|
||||
-- History matches, ranked after shell completions.
|
||||
for _, cmd in ipairs(history) do
|
||||
if cmd:sub(1, #query) == query and not seen[cmd] then
|
||||
seen[cmd] = true
|
||||
results[#results + 1] = {
|
||||
id = "fill:" .. cmd,
|
||||
title = cmd,
|
||||
subtitle = noctalia.tr("history_subtitle"),
|
||||
glyph = "history",
|
||||
}
|
||||
end
|
||||
if #results >= 1 + MAX_SUGGESTIONS then
|
||||
break
|
||||
end
|
||||
end
|
||||
launcher.setResults(query, results)
|
||||
end)
|
||||
end
|
||||
|
||||
function onActivate(id)
|
||||
if id == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local kind, value = id:match("^(%a+):(.+)$")
|
||||
if not kind or not value then
|
||||
noctalia.log("shell-command: onActivate received malformed id: " .. tostring(id))
|
||||
return
|
||||
end
|
||||
|
||||
if kind == "fill" then
|
||||
launcher.setQuery(value)
|
||||
elseif kind == "run" then
|
||||
recordHistory(value)
|
||||
noctalia.runInTerminal(buildCommand(value))
|
||||
elseif kind == "cdgo" then
|
||||
noctalia.runInTerminal(buildCdCommand(value))
|
||||
else
|
||||
noctalia.log("shell-command: unknown activation kind: " .. kind)
|
||||
end
|
||||
end
|
||||
|
||||
loadHistory()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"hint_title": "Run a shell command",
|
||||
"hint_subtitle": "Type a command after /sh and press Enter to open it in your terminal",
|
||||
"run_title": "Run: {command}",
|
||||
"run_subtitle": "Opens in your default terminal",
|
||||
"run_subtitle_found": "{command} found — opens in your default terminal",
|
||||
"history_subtitle": "Recent command",
|
||||
"suggest_subtitle": "Suggestion",
|
||||
"snap_subtitle": "Complete: {command} — Enter to run",
|
||||
"snippet_subtitle": "Snippet",
|
||||
"cd_open_title": "Open in: {path}",
|
||||
"cd_open_subtitle": "Opens terminal in this folder",
|
||||
"cd_empty": "No folders found",
|
||||
"cd_empty_subtitle": "Type path or check workspace setting",
|
||||
"settings": {
|
||||
"default_workspace": {
|
||||
"label": "Default Workspace",
|
||||
"description": "Working directory commands run in. Leave empty to use the terminal's current directory."
|
||||
},
|
||||
"snippets": {
|
||||
"label": "Snippets",
|
||||
"description": "Commands shown when /sh is empty. Each entry is one command."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"hint_title": "Chạy lệnh shell",
|
||||
"hint_subtitle": "Gõ lệnh sau /sh rồi nhấn Enter để mở trong terminal",
|
||||
"run_title": "Chạy: {command}",
|
||||
"run_subtitle": "Mở trong terminal mặc định",
|
||||
"run_subtitle_found": "Tìm thấy {command} — mở trong terminal mặc định",
|
||||
"history_subtitle": "Lệnh gần đây",
|
||||
"suggest_subtitle": "Gợi ý",
|
||||
"snap_subtitle": "Hoàn tất: {command} — Enter để chạy",
|
||||
"snippet_subtitle": "Snippet",
|
||||
"cd_open_title": "Mở trong: {path}",
|
||||
"cd_open_subtitle": "Mở terminal trong thư mục này",
|
||||
"cd_empty": "Không tìm thấy thư mục",
|
||||
"cd_empty_subtitle": "Gõ đường dẫn hoặc kiểm tra cài đặt workspace",
|
||||
"settings": {
|
||||
"default_workspace": {
|
||||
"label": "Thư mục làm việc",
|
||||
"description": "Thư mục lệnh chạy trong đó. Để trống dùng thư mục hiện tại của terminal."
|
||||
},
|
||||
"snippets": {
|
||||
"label": "Snippets",
|
||||
"description": "Lệnh hiện khi /sh trống. Mỗi mục là một lệnh."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user