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