diff --git a/tmux-provider/README.md b/tmux-provider/README.md new file mode 100644 index 0000000..dce2539 --- /dev/null +++ b/tmux-provider/README.md @@ -0,0 +1,59 @@ +# tmux Provider + +A launcher provider plugin for searching running tmux sessions, or tmuxp configurations, and +attaching the sessions. + +## Plugin + +| Field | Value | +| --------------- | ----------------------------- | +| ID | `dunarand/tmux-provider` | +| Entry | Launcher provider: `provider` | +| Launcher Prefix | `/tm` | + +## Requirements + +- Noctalia v5.0.0 or higher +- Requires `tmux`: [Install tmux](https://github.com/tmux/tmux/wiki/installing) +- `tmuxp`: Optional dependency, [install tmuxp](https://tmuxp.git-pull.com/) + +tmuxp is completely optional. If you want tmuxp configurations to be included in the search, enable +tmuxp option in the plugin settings. + +![](./assets/preview-2.png) + +## Usage + +Simply open your launcher and type the session you're looking for after the `/tm` prefix. + +![](./assets/preview-1.png) + +Selecting an entry and pressing Return / Enter launches the default terminal and attaches the tmux +session. + +## Settings + +| Setting | Type | Default | Description | +| ----------- | ------ | ------- | ---------------------------------------------------------- | +| `use_tmuxp` | `bool` | `false` | Enables tmuxp configurations to be included in the search. | + +## Notes + +**Limitations:** + +- With the current implementation, we attach sessions via the following commands: + + - **tmux:** + + ``` + tmux attach -t + ``` + + - **tmuxp:** + + ``` + tmuxp load + ``` + +- The current implementation only utilizes tmuxp. Other tmux configuration tools such as tmuxinator + will be added in the future. diff --git a/tmux-provider/assets/preview-1.png b/tmux-provider/assets/preview-1.png new file mode 100644 index 0000000..420ff12 Binary files /dev/null and b/tmux-provider/assets/preview-1.png differ diff --git a/tmux-provider/assets/preview-2.png b/tmux-provider/assets/preview-2.png new file mode 100644 index 0000000..d281fa1 Binary files /dev/null and b/tmux-provider/assets/preview-2.png differ diff --git a/tmux-provider/plugin.toml b/tmux-provider/plugin.toml new file mode 100644 index 0000000..5f96596 --- /dev/null +++ b/tmux-provider/plugin.toml @@ -0,0 +1,25 @@ +id = "dunarand/tmux-provider" +name = "tmux Provider" +version = "0.1.0" +plugin_api = 3 +author = "dunarand" +license = "MIT" +icon = "terminal" +description = "Find and attach tmux sessions from the launcher. Type /tm to list tmux sessions or tmuxp configurations." +tags = ["launcher", "productivity", "development"] +dependencies = ["tmux", "tmuxp"] + +[[setting]] +key = "use_tmuxp" +type = "bool" +label_key = "settings.use_tmuxp.label" +description_key = "settings.use_tmuxp.description" +default = false + +[[launcher_provider]] +id = "provider" +entry = "tmux_provider.luau" +prefix = "tm" +glyph = "terminal" +include_in_global_search = false +debounce_ms = 0 diff --git a/tmux-provider/thumbnail.webp b/tmux-provider/thumbnail.webp new file mode 100644 index 0000000..e3e2a5e Binary files /dev/null and b/tmux-provider/thumbnail.webp differ diff --git a/tmux-provider/tmux_provider.luau b/tmux-provider/tmux_provider.luau new file mode 100644 index 0000000..7397302 --- /dev/null +++ b/tmux-provider/tmux_provider.luau @@ -0,0 +1,237 @@ +local tinsert = table.insert +local tsort = table.sort +local sformat = string.format + +-- Per-query in-flight state: both tmux and tmuxp results must land before +-- we render, since either can be requested for the same query. +local sessionsCache +local tmuxpCache +local pendingQuery: string? = nil +local pendingTmux = false +local pendingTmuxp = false + +local function tmuxInstalled(): boolean + return noctalia.commandExists("tmux") +end + +local function tmuxpInstalled(): boolean + return noctalia.commandExists("tmuxp") +end + +local function shellQuote(s: string): string + return "'" .. s:gsub("'", "'\"'\"'") .. "'" +end + +-- Parses `tmux ls` output into a list of { name, attached } +-- Format per line: "name: N windows (created ...) [(attached)]" +local function parseTmuxLs(output: string) + local sessions = {} + for line in output:gmatch("[^\r\n]+") do + local name = line:match("^([^:]+):") + if name then + local attached = line:find("%(attached%)") ~= nil + tinsert(sessions, { name = name, attached = attached }) + end + end + return sessions +end + +-- Parses `tmuxp ls --json` output into a list of { name, session_name } +local function parseTmuxpLs(output: string) + local configs = {} + + local data, err = noctalia.json.decode(output) + if not data or not data.workspaces then + if err then + noctalia.log("tmux-provider: failed to parse tmuxp ls --json output: " .. tostring(err)) + end + return configs + end + + for _, workspace in ipairs(data.workspaces) do + if workspace.name then + tinsert(configs, { + name = workspace.name, + session_name = workspace.session_name or workspace.name, + }) + end + end + + return configs +end + +-- Builds the combined result list: running tmux sessions first, then tmuxp +-- configs that aren't already running as a live session. +local function buildResults(query: string) + local results = {} + local runningNames = {} + + if sessionsCache then + for _, session in ipairs(sessionsCache) do + runningNames[session.name] = true + + local score: number? + if query == "" then + score = 0 + else + score = noctalia.fuzzyScore(query, session.name) + end + + if score ~= nil then + tinsert(results, { + id = "attach:" .. session.name, + title = session.name, + subtitle = session.attached and noctalia.tr("session_attached") + or noctalia.tr("session_detached"), + glyph = "terminal-2", + score = score, + }) + end + end + end + + if tmuxpCache then + for _, config in ipairs(tmuxpCache) do + if not runningNames[config.session_name] then + local score: number? + if query == "" then + score = -1 -- rank below live sessions when query is empty + else + score = noctalia.fuzzyScore(query, config.name) + end + + if score ~= nil then + tinsert(results, { + id = "tmuxp:" .. config.session_name, + title = config.name, + subtitle = noctalia.tr("tmuxp_config"), + glyph = "file-text", + score = score, + }) + end + end + end + end + + tsort(results, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + + return results +end + +local function showResults(query: string) + local results = buildResults(query) + + if #results > 0 then + launcher.setResults(query, results) + return + end + + if not tmuxInstalled() then + launcher.setResults(query, { + { + id = "", + title = noctalia.tr("tmux_not_found"), + subtitle = noctalia.tr("tmux_not_found_subtitle"), + glyph = "alert-triangle", + }, + }) + return + end + + launcher.setResults(query, { + { + id = "", + title = noctalia.tr("no_sessions_found"), + subtitle = noctalia.tr("no_sessions_subtitle"), + glyph = "loader", + }, + }) +end + +-- Renders once every in-flight fetch for this query has landed. +local function maybeShowResults(query: string) + if pendingQuery ~= query then + return -- a newer query superseded this one + end + if pendingTmux or pendingTmuxp then + return -- still waiting on one of the two sources + end + showResults(query) +end + +local function refreshSessions(query: string) + pendingQuery = query + + if tmuxInstalled() then + pendingTmux = true + noctalia.runAsync("tmux ls", function(result) + if result.exitCode == 0 then + sessionsCache = parseTmuxLs(result.stdout) + else + -- tmux exits non-zero both on "no server running" (expected, + -- empty list) and on real errors. We can't reliably match the + -- "no server" message across tmux versions/locales, so we just + -- treat any non-zero exit as "no sessions" and log it at low + -- severity for diagnostics rather than surfacing it as an error. + if result.stderr and result.stderr ~= "" then + noctalia.log("tmux-provider: tmux ls: " .. result.stderr) + end + sessionsCache = {} + end + pendingTmux = false + maybeShowResults(query) + end) + else + sessionsCache = {} + pendingTmux = false + end + + local use_tmuxp: boolean = noctalia.getConfig("use_tmuxp") or false + if use_tmuxp and tmuxpInstalled() then + pendingTmuxp = true + noctalia.runAsync("tmuxp ls --json", function(result) + if result.exitCode == 0 then + tmuxpCache = parseTmuxpLs(result.stdout) + else + noctalia.log( + "tmux-provider: tmuxp ls --json failed: " + .. tostring(result.stderr or result.stdout) + ) + tmuxpCache = {} + end + pendingTmuxp = false + maybeShowResults(query) + end) + else + tmuxpCache = {} + pendingTmuxp = false + end + + -- In case both sources were skipped synchronously (e.g. neither installed) + maybeShowResults(query) +end + +function onQuery(query: string) + query = noctalia.string.trim(query) + refreshSessions(query) +end + +function onActivate(id: string) + if id == "" then + return + end + + local kind, name = id:match("^(%a+):(.+)$") + if not kind or not name then + noctalia.log("tmux-provider: onActivate received malformed id: " .. tostring(id)) + return + end + + if kind == "attach" then + noctalia.runInTerminal(sformat("tmux attach -t %s", shellQuote(name))) + elseif kind == "tmuxp" then + noctalia.runInTerminal(sformat("tmuxp load %s", shellQuote(name))) + end +end diff --git a/tmux-provider/translations/en.json b/tmux-provider/translations/en.json new file mode 100644 index 0000000..cb1eb7e --- /dev/null +++ b/tmux-provider/translations/en.json @@ -0,0 +1,15 @@ +{ + "session_attached": "Attached", + "session_detached": "Detached", + "tmuxp_config": "tmuxp config", + "tmux_not_found": "tmux not found", + "tmux_not_found_subtitle": "Install tmux to use this provider", + "no_sessions_found": "No sessions found", + "no_sessions_subtitle": "Start a tmux session to see it here", + "settings": { + "use_tmuxp": { + "label": "Enable tmuxp", + "description": "Also list tmuxp session configurations (via `tmuxp ls`)." + } + } +}