diff --git a/extensions/ai/backslash.toml b/extensions/ai/backslash.toml index 7c41c69..094bda9 100644 --- a/extensions/ai/backslash.toml +++ b/extensions/ai/backslash.toml @@ -2,9 +2,9 @@ id = "ai" title = "AI" description = "Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama" -version = "1.1.0" +version = "1.2.0" author = "Backslash" -icon = "✨" +icon = "bs-sparkle" license = "MIT" # The text commands work on what you copied last. diff --git a/extensions/ai/chat.ts b/extensions/ai/chat.ts index 9ef316c..fe469d5 100644 --- a/extensions/ai/chat.ts +++ b/extensions/ai/chat.ts @@ -1,7 +1,7 @@ -import { action, clipboard, close, detail, field, form, replace, storage } from "@backslash/api"; -import { languageRule, type Message, type Prefs } from "./lib.ts"; +import { action, clipboard, close, detail, field, form, Icons, meta, replace, storage } from "@backslash/api"; +import { languageRule, type Message, type Prefs, provider } from "./lib.ts"; import { canPaste, pasteText } from "./paste.ts"; -import { answer, modelName } from "./llm.ts"; +import { answer } from "./llm.ts"; export interface Conversation { id: string; @@ -29,6 +29,19 @@ const system = (prefs: Prefs) => languageRule(prefs) }`; +const questions = (c: Conversation) => c.messages.filter((m) => m.role === "user").length; + +function chatMetadata(prefs: Prefs, c: Conversation) { + const p = provider(prefs); + return [ + meta.text("Provider", p.name, { icon: Icons.sparkle, color: "purple" }), + meta.text("Model", p.model, { icon: Icons.cpu }), + meta.separator(), + meta.text("Questions", questions(c), { icon: Icons.comment }), + meta.text("Updated", new Date(c.updated).toLocaleString(), { icon: Icons.clock }), + ]; +} + /** "**You** question" then the answer, for each turn. */ function transcript(messages: Message[]): string { return messages.map((m) => (m.role === "user" ? `> ${m.content.replace(/\n/g, "\n> ")}` : m.content)).join("\n\n"); @@ -54,7 +67,8 @@ export function chat(prefs: Prefs, previous: Conversation | undefined, question? return detail({ title: convo.title, - metadata: { Model: modelName(prefs) }, + metadata: chatMetadata(prefs, convo), + status: `${questions(convo)} question${questions(convo) === 1 ? "" : "s"}`, markdown: question ? async function* ({ signal }) { const before = transcript(convo.messages); @@ -71,13 +85,13 @@ export function chat(prefs: Prefs, previous: Conversation | undefined, question? action("Copy Answer", async () => { await clipboard.write(last); return close("Answer copied"); - }, { icon: "edit-copy" }), - action("Ask a Follow-up…", () => followUp, { icon: "mail-reply-sender", shortcut: "r" }), - ...(canPaste ? [action("Paste Answer", () => pasteText(last), { icon: "edit-paste", shortcut: "p" })] : []), + }, { icon: Icons.copy }), + action("Ask a Follow-up…", () => followUp, { icon: Icons.comment, shortcut: "r" }), + ...(canPaste ? [action("Paste Answer", () => pasteText(last), { icon: Icons.paste, shortcut: "p" })] : []), action("Copy Conversation", async () => { await clipboard.write(transcript(convo.messages)); return close("Conversation copied"); - }), + }, { icon: Icons.copy, section: "Copy" }), ], }); } diff --git a/extensions/ai/commands/ask.ts b/extensions/ai/commands/ask.ts index c16cd3a..f83d03f 100644 --- a/extensions/ai/commands/ask.ts +++ b/extensions/ai/commands/ask.ts @@ -1,50 +1,89 @@ -import { action, type Item, list, refresh } from "@backslash/api"; -import { chat, conversations, remove } from "../chat.ts"; +import { action, Icons, type Item, list, meta, refresh } from "@backslash/api"; +import { chat, type Conversation, conversations, remove } from "../chat.ts"; import type { Prefs } from "../lib.ts"; +import { modelName } from "../llm.ts"; -const ago = (ms: number) => { +const short = (ms: number) => { const m = Math.floor((Date.now() - ms) / 60000); - return m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; + return m < 1 ? "now" : m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`; }; +/** Today, Yesterday, This Week, Older: like the clipboard history. */ +export function when(ms: number, now = new Date()): string { + const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + if (ms >= day) return "Today"; + if (ms >= day - 86_400_000) return "Yesterday"; + if (ms >= day - 6 * 86_400_000) return "This Week"; + return "Older"; +} + +const oneLine = (s: string) => s.replace(/[#*_`>]/g, "").replace(/\s+/g, " ").trim(); + +function conversationItem(prefs: Prefs, c: Conversation, q: string): Item { + const lastAnswer = [...c.messages].reverse().find((m) => m.role === "assistant")?.content ?? ""; + const lastQuestion = [...c.messages].reverse().find((m) => m.role === "user")?.content ?? ""; + const questions = c.messages.filter((m) => m.role === "user").length; + return { + id: c.id, + title: c.title, + subtitle: oneLine(lastAnswer).slice(0, 80), + icon: { source: Icons.comment, tint: "purple" }, + section: when(c.updated), + accessories: [ + ...(questions > 1 ? [{ icon: Icons.comment, text: String(questions), tooltip: `${questions} questions` }] : []), + { text: short(c.updated), tooltip: new Date(c.updated).toLocaleString() }, + ], + detail: { + markdown: `> ${oneLine(lastQuestion).slice(0, 200)}\n\n${ + lastAnswer.length > 1200 ? `${lastAnswer.slice(0, 1200)}…` : lastAnswer + }`, + metadata: [ + meta.text("Questions", questions, { icon: Icons.comment }), + meta.text("Updated", new Date(c.updated).toLocaleString(), { icon: Icons.clock }), + ], + }, + actions: [ + action("Open Conversation", () => chat(prefs, c), { icon: Icons.comment }), + ...(q ? [action(`Continue with “${q}”`, () => chat(prefs, c, q), { icon: Icons.arrowRight })] : []), + action("Delete Conversation", async () => { + await remove(c.id); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: "Delete this conversation?", section: "Danger" }), + ], + }; +} + +let count = 0; + export default list({ placeholder: "Ask anything…", - empty: { title: "Ask anything", description: "Type a question, then press Enter" }, + empty: { title: "Ask anything", description: "Type a question, then press Enter", icon: Icons.sparkle }, + status: () => `${count} conversation${count === 1 ? "" : "s"}`, async search(query, { preferences }) { const prefs = preferences as Prefs; const q = query.trim(); const items: Item[] = []; if (q) { + let model = ""; + try { + model = modelName(prefs); + } catch { /* shown when asking */ } items.push({ id: "ask", title: q, subtitle: "Ask AI", - icon: "✨", + icon: { source: Icons.sparkle, tint: "purple" }, section: "Ask", - actions: [action("Ask", () => chat(prefs, undefined, q), { icon: "mail-send" })], + accessories: model ? [{ tag: model }] : [], + actions: [action("Ask", () => chat(prefs, undefined, q), { icon: Icons.sparkle })], }); } + const all = await conversations(); + count = all.length; const words = q.toLowerCase().split(/\s+/).filter(Boolean); - for (const c of await conversations()) { + for (const c of all) { const text = c.messages.map((m) => m.content).join(" ").toLowerCase(); - if (!words.every((w) => text.includes(w))) continue; - const lastAnswer = [...c.messages].reverse().find((m) => m.role === "assistant")?.content ?? ""; - items.push({ - id: c.id, - title: c.title, - subtitle: lastAnswer.replace(/\s+/g, " ").slice(0, 120), - icon: "💬", - section: "Conversations", - accessories: [{ text: ago(c.updated) }], - actions: [ - action("Open Conversation", () => chat(prefs, c), { icon: "document-open" }), - ...(q ? [action(`Continue with “${q}”`, () => chat(prefs, c, q), { icon: "mail-reply-sender" })] : []), - action("Delete", async () => { - await remove(c.id); - return refresh(); - }, { destructive: true, confirm: "Delete this conversation?" }), - ], - }); + if (words.every((w) => text.includes(w))) items.push(conversationItem(prefs, c, q)); } return items; }, diff --git a/extensions/ai/commands/translate.ts b/extensions/ai/commands/translate.ts index 911f8fc..4245fcb 100644 --- a/extensions/ai/commands/translate.ts +++ b/extensions/ai/commands/translate.ts @@ -1,4 +1,4 @@ -import { action, type Context, type DetailSpec, replace } from "@backslash/api"; +import { action, type Context, type DetailSpec, Icons, replace } from "@backslash/api"; import type { Prefs } from "../lib.ts"; import { copiedText, nothingCopied, toolView } from "../tools.ts"; @@ -11,7 +11,10 @@ function translate(prefs: Prefs, text: string, to: string): DetailSpec { other ? ` If it is already in ${to}, translate it into ${other}.` : "" } Keep the formatting. Reply with the translation only.`; const others = LANGUAGES.filter((l) => l.toLowerCase() !== to.toLowerCase()).map((l) => - action(`Translate into ${l}`, () => replace(translate(prefs, text, l)), { icon: "preferences-desktop-locale" }) + action(`Translate into ${l}`, () => replace(translate(prefs, text, l)), { + icon: Icons.globe, + section: "Other Languages", + }) ); return toolView(prefs, `Translate into ${to}`, instructions, text, others); } diff --git a/extensions/ai/tools.ts b/extensions/ai/tools.ts index ec1a161..e9b788f 100644 --- a/extensions/ai/tools.ts +++ b/extensions/ai/tools.ts @@ -1,8 +1,8 @@ -import { type Action, action, clipboard, close, type Context, detail, replace } from "@backslash/api"; +import { type Action, action, clipboard, close, type Context, detail, Icons, meta, replace } from "@backslash/api"; import { chat } from "./chat.ts"; -import { languageRule, type Prefs } from "./lib.ts"; +import { languageRule, type Prefs, provider } from "./lib.ts"; import { canPaste, pasteText } from "./paste.ts"; -import { answer, modelName } from "./llm.ts"; +import { answer } from "./llm.ts"; /** The streamed result of an instruction applied to `text`; Enter copies it. */ export function toolView( @@ -16,7 +16,13 @@ export function toolView( let result = ""; return detail({ title, - metadata: { Text: text.length > 400 ? `${text.slice(0, 400)}…` : text, Model: modelName(prefs) }, + metadata: [ + meta.text("Model", provider(prefs).model, { icon: Icons.sparkle, color: "purple" }), + meta.text("Words", text.split(/\s+/).filter(Boolean).length, { icon: Icons.text }), + meta.separator(), + meta.text("Text", text.length > 400 ? `${text.slice(0, 400)}…` : text), + ], + status: provider(prefs).name, async *markdown({ signal }) { for await (const t of answer(prefs, system, [{ role: "user", content: text }], signal, "low")) { result = t; @@ -24,11 +30,11 @@ export function toolView( } }, actions: [ - ...(canPaste ? [action("Paste Result", () => pasteText(result), { icon: "edit-paste" })] : []), + ...(canPaste ? [action("Paste Result", () => pasteText(result), { icon: Icons.paste })] : []), action("Copy Result", async () => { await clipboard.write(result); return close("Copied"); - }, { icon: "edit-copy" }), + }, { icon: Icons.copy }), ...more, action("Continue in Chat", () => replace(chat(prefs, { @@ -36,7 +42,7 @@ export function toolView( title: `${title}: ${text.slice(0, 60)}`, messages: [{ role: "user", content: `${title}:\n\n${text}` }, { role: "assistant", content: result }], updated: Date.now(), - })), { icon: "mail-reply-sender", shortcut: "r" }), + })), { icon: Icons.comment, shortcut: "r", section: "Chat" }), ], }); } diff --git a/extensions/browser/backslash.toml b/extensions/browser/backslash.toml index 85438e1..6a5ea62 100644 --- a/extensions/browser/backslash.toml +++ b/extensions/browser/backslash.toml @@ -2,9 +2,9 @@ id = "browser" title = "Browser" description = "History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "web-browser" +icon = "bs-globe" license = "MIT" # Browsers lock their databases: they are copied to /tmp/backslash-browser to be read. diff --git a/extensions/browser/commands/search.ts b/extensions/browser/commands/search.ts index b14827b..2ee6d5e 100644 --- a/extensions/browser/commands/search.ts +++ b/extensions/browser/commands/search.ts @@ -1,36 +1,68 @@ -import { copy, type Item, list, open } from "@backslash/api"; +import { copy, Icons, type Item, list, meta, open } from "@backslash/api"; import { favicons, type Page, pages, profiles } from "../browsers.ts"; import { ago, domain } from "../lib.ts"; +const when = (ms: number) => + new Date(ms).toLocaleString("en", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + function item(p: Page, several: boolean, icon?: string): Item { const host = domain(p.url); return { - id: p.url, - title: p.title, + id: `${p.bookmark ? "b" : "h"}:${p.url}`, + title: p.title || host, subtitle: host, - icon: icon ?? (p.bookmark ? "⭐" : "web-browser"), + icon: icon ?? (p.bookmark ? { source: Icons.starFilled, tint: "yellow" } : { source: Icons.globe, tint: "gray" }), section: p.bookmark ? "Bookmarks" : "History", keywords: [p.url, host], accessories: [ - ...(several ? [{ tag: p.browser }] : []), + ...(p.visits > 1 ? [{ text: `${p.visits}×`, tooltip: `${p.visits} visits` }] : []), + ...(several ? [{ text: p.browser }] : []), ...(p.time > 0 ? [{ text: ago(p.time) }] : []), ], + detail: { + metadata: [ + meta.link("Address", p.url.length > 80 ? `${p.url.slice(0, 80)}…` : p.url, p.url), + meta.text("Site", host, { icon: Icons.globe }), + ...(p.bookmark ? [meta.text("Bookmark", "Yes", { icon: Icons.starFilled, color: "yellow" })] : []), + ...(p.time > 0 ? [meta.text(p.bookmark ? "Added" : "Last visit", when(p.time), { icon: Icons.clock })] : []), + ...(p.visits ? [meta.text("Visits", p.visits)] : []), + meta.text("Browser", p.browser), + ], + }, actions: [ open(p.url, "Open in Browser"), - copy(p.url, "Copy URL"), - copy(`[${p.title}](${p.url})`, "Copy as Markdown Link"), + copy(p.url, "Copy URL", { shortcut: "c" }), + copy(`[${p.title}](${p.url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(p.title, "Copy Title", { section: "Copy" }), ], }; } export default list({ placeholder: "Search history and bookmarks…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "bookmarks", title: "Bookmarks" }, + { value: "history", title: "History" }, + ], + }, empty: { title: "No browser found", description: "Firefox, Chrome, Brave, Chromium, Edge and Vivaldi are supported", + icon: "bs-globe", }, - async *items() { - const all = pages(); + status: (items) => `${items.length} page${items.length === 1 ? "" : "s"}`, + async *items({ filter }) { + let all = pages(); + if (filter === "bookmarks") all = all.filter((p) => p.bookmark); + if (filter === "history") all = all.filter((p) => !p.bookmark); const several = new Set(profiles().map((p) => p.browser)).size > 1; // Bookmarks first, then history from the most recent visit. all.sort((a, b) => Number(b.bookmark) - Number(a.bookmark) || b.time - a.time); diff --git a/extensions/browser/commands/tabs.ts b/extensions/browser/commands/tabs.ts index a1ce1b7..cb776b3 100644 --- a/extensions/browser/commands/tabs.ts +++ b/extensions/browser/commands/tabs.ts @@ -1,4 +1,4 @@ -import { copy, list, open } from "@backslash/api"; +import { copy, Icons, list, meta, open } from "@backslash/api"; import { profiles } from "../browsers.ts"; import { ago, domain, firefoxTabs, mozLz4, type Tab } from "../lib.ts"; @@ -17,15 +17,29 @@ function readTabs(): Tab[] { export default list({ placeholder: "Search open tabs…", - empty: { title: "No open tabs", description: "Firefox's session file was not found" }, + empty: { title: "No open tabs", description: "Firefox's session file was not found", icon: "bs-window" }, + status: (items) => `${items.length} open tab${items.length === 1 ? "" : "s"}`, items: () => readTabs().map((t) => ({ id: t.url, title: t.title, subtitle: domain(t.url), - icon: t.pinned ? "📌" : "web-browser", + icon: t.pinned ? { source: Icons.pin, tint: "orange" as const } : { source: Icons.globe, tint: "gray" as const }, + section: t.pinned ? "Pinned" : "Tabs", keywords: [t.url], accessories: t.lastAccessed ? [{ text: ago(t.lastAccessed) }] : [], - actions: [open(t.url, "Open in Browser"), copy(t.url, "Copy URL")], + detail: { + metadata: [ + meta.link("Address", t.url.length > 80 ? `${t.url.slice(0, 80)}…` : t.url, t.url), + meta.text("Site", domain(t.url), { icon: Icons.globe }), + ...(t.lastAccessed ? [meta.text("Last seen", ago(t.lastAccessed), { icon: Icons.clock })] : []), + ...(t.pinned ? [meta.text("Pinned", "Yes", { icon: Icons.pin })] : []), + ], + }, + actions: [ + open(t.url, "Open in Browser"), + copy(t.url, "Copy URL", { shortcut: "c" }), + copy(`[${t.title}](${t.url})`, "Copy as Markdown Link"), + ], })), }); diff --git a/extensions/docker/backslash.toml b/extensions/docker/backslash.toml index 6593cbe..75fde40 100644 --- a/extensions/docker/backslash.toml +++ b/extensions/docker/backslash.toml @@ -2,9 +2,9 @@ id = "docker" title = "Containers" description = "Docker and Podman containers: start, stop, logs" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "utilities-terminal" +icon = "bs-box" license = "MIT" [permissions] diff --git a/extensions/docker/commands/containers.ts b/extensions/docker/commands/containers.ts index 79f8ec7..2676f52 100644 --- a/extensions/docker/commands/containers.ts +++ b/extensions/docker/commands/containers.ts @@ -1,4 +1,4 @@ -import { action, copy, detail, exec, type Item, list, refresh } from "@backslash/api"; +import { action, copy, detail, exec, Icons, type Item, list, meta, open, refresh } from "@backslash/api"; interface Container { ID: string; @@ -7,6 +7,30 @@ interface Container { State: string; Status: string; Ports?: string; + CreatedAt?: string; + RunningFor?: string; + Networks?: string; + Labels?: string | Record; +} + +/** `0.0.0.0:8080->80/tcp, :::8080->80/tcp` → host ports reachable in a browser. */ +export function hostPorts(ports: string | undefined): { host: number; container: string }[] { + const seen = new Set(); + const out: { host: number; container: string }[] = []; + for (const m of (ports ?? "").matchAll(/:(\d+)->(\d+\/\w+)/g)) { + const host = Number(m[1]); + if (!seen.has(host)) { + seen.add(host); + out.push({ host, container: m[2] }); + } + } + return out; +} + +/** The Compose project a container belongs to, if any. */ +function project(c: Container): string | undefined { + if (typeof c.Labels === "object" && c.Labels) return c.Labels["com.docker.compose.project"]; + return c.Labels?.match(/com\.docker\.compose\.project=([^,]+)/)?.[1]; } /** Docker first, Podman otherwise (same CLI). */ @@ -24,6 +48,8 @@ async function engine(): Promise { function item(cli: string, c: Container): Item { const running = c.State === "running"; + const ports = hostPorts(c.Ports); + const compose = project(c); const run = (verb: string) => async () => { await exec(cli, [verb, c.ID]); return refresh(); @@ -32,41 +58,79 @@ function item(cli: string, c: Container): Item { id: c.ID, title: c.Names, subtitle: c.Image, - icon: "utilities-terminal", - section: running ? "Running" : "Stopped", - keywords: [c.ID, c.Image], - accessories: [{ text: c.Status }, { tag: c.State, color: running ? "green" : "gray" }], + icon: { source: Icons.box, tint: running ? "green" : c.State === "exited" ? "gray" : "orange" }, + section: compose ? `${compose} (Compose)` : running ? "Running" : "Stopped", + keywords: [c.ID, c.Image, compose ?? ""], + accessories: [ + ...ports.slice(0, 2).map((p) => ({ text: `:${p.host}`, icon: Icons.plug, tooltip: `→ ${p.container}` })), + { text: running ? (c.RunningFor ?? c.Status) : c.Status, color: running ? undefined : "gray" as const }, + ...(running ? [] : [{ tag: c.State, color: "gray" as const }]), + ], + detail: { + metadata: [ + meta.text("State", c.Status, { icon: Icons.box, color: running ? "green" : undefined }), + meta.text("Image", c.Image, { icon: Icons.package }), + ...(ports.length ? [meta.tags("Ports", ports.map((p) => `${p.host} → ${p.container}`))] : []), + ...(compose ? [meta.text("Compose", compose, { icon: Icons.apps })] : []), + ...(c.Networks ? [meta.text("Networks", c.Networks, { icon: Icons.globe })] : []), + meta.separator(), + ...(c.CreatedAt ? [meta.text("Created", c.CreatedAt.replace(/ [+-]\d{4} \w+$/, ""))] : []), + meta.text("ID", c.ID.slice(0, 12)), + meta.text("Engine", cli === "docker" ? "Docker" : "Podman"), + ], + }, actions: [ action("Show Logs", async () => { - const logs = await exec(cli, ["logs", "--tail", "100", c.ID]).catch((e) => e.message); + const logs = await exec(cli, ["logs", "--tail", "200", c.ID]).catch((e) => e.message); return detail({ title: c.Names, - markdown: `# ${c.Names}\n\n\`\`\`\n${logs || "No logs."}\n\`\`\``, - metadata: { Image: c.Image, Status: c.Status, Ports: c.Ports || "—", ID: c.ID }, + markdown: `\`\`\`\n${logs || "No logs."}\n\`\`\``, + metadata: [ + meta.text("State", c.Status, { color: running ? "green" : undefined }), + meta.text("Image", c.Image), + ...(ports.length ? [meta.tags("Ports", ports.map((p) => String(p.host)))] : []), + meta.text("ID", c.ID.slice(0, 12)), + ], + status: "Last 200 lines", + actions: [copy(logs, "Copy Logs")], }); - }), + }, { icon: Icons.text }), + ...ports.slice(0, 1).map((p) => + open(`http://localhost:${p.host}`, `Open localhost:${p.host}`, { shortcut: "o" }) + ), running - ? action("Stop", run("stop"), { shortcut: "s" }) - : action("Start", run("start"), { shortcut: "s" }), - action("Restart", run("restart"), { shortcut: "r" }), - copy(`${cli} exec -it ${c.Names} sh`, "Copy Shell Command"), - copy(c.ID, "Copy ID"), - action("Remove", run("rm"), { destructive: true, confirm: `Remove ${c.Names}?` }), + ? action("Stop", run("stop"), { icon: Icons.stop, shortcut: "s", section: "Container" }) + : action("Start", run("start"), { icon: Icons.play, shortcut: "s", section: "Container" }), + action("Restart", run("restart"), { icon: Icons.refresh, shortcut: "r", section: "Container" }), + copy(`${cli} exec -it ${c.Names} sh`, "Copy Shell Command", { icon: Icons.terminal, section: "Copy" }), + copy(c.ID, "Copy ID", { section: "Copy" }), + action("Remove", run("rm"), { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${c.Names}?`, + section: "Danger", + }), ], }; } +let running = 0; + export default list({ placeholder: "Filter containers…", - empty: { title: "No containers", description: "Nothing created yet" }, + empty: { title: "No containers", description: "Nothing created yet", icon: "bs-box" }, + status: (items) => `${running} running · ${items.length} container${items.length === 1 ? "" : "s"}`, items: async () => { const cli = await engine(); const out = await exec(cli, ["ps", "-a", "--format", "{{json .}}"]); const containers = out.split("\n").filter(Boolean).map((l) => JSON.parse(l) as Container) // Podman prints `Names` as an array. .map((c) => ({ ...c, Names: Array.isArray(c.Names) ? c.Names.join(", ") : c.Names })); - return containers.sort((a, b) => Number(b.State === "running") - Number(a.State === "running")).map((c) => - item(cli, c) - ); + running = containers.filter((c) => c.State === "running").length; + // Compose projects together, running first. + return containers.sort((a, b) => + (project(a) ?? "~").localeCompare(project(b) ?? "~") || + Number(b.State === "running") - Number(a.State === "running") + ).map((c) => item(cli, c)); }, }); diff --git a/extensions/docker/docker_test.ts b/extensions/docker/docker_test.ts new file mode 100644 index 0000000..e8e0498 --- /dev/null +++ b/extensions/docker/docker_test.ts @@ -0,0 +1,10 @@ +import { assertEquals } from "@std/assert"; +import { hostPorts } from "./commands/containers.ts"; + +Deno.test("published ports", () => { + assertEquals(hostPorts("0.0.0.0:8080->80/tcp, :::8080->80/tcp, 0.0.0.0:5432->5432/tcp"), [ + { host: 8080, container: "80/tcp" }, + { host: 5432, container: "5432/tcp" }, + ]); + assertEquals(hostPorts(""), []); +}); diff --git a/extensions/emoji/backslash.toml b/extensions/emoji/backslash.toml index a96d337..de0ed7f 100644 --- a/extensions/emoji/backslash.toml +++ b/extensions/emoji/backslash.toml @@ -2,7 +2,7 @@ id = "emoji" title = "Emoji & Symbols" description = "Search emoji and symbols by name and copy them" -version = "1.2.0" +version = "1.3.0" author = "Backslash" icon = "😀" license = "MIT" diff --git a/extensions/emoji/commands/search.ts b/extensions/emoji/commands/search.ts index 8d42cb5..dd879b8 100644 --- a/extensions/emoji/commands/search.ts +++ b/extensions/emoji/commands/search.ts @@ -1,5 +1,5 @@ import * as api from "@backslash/api"; -import { copy, list } from "@backslash/api"; +import { copy, list, storage } from "@backslash/api"; // paste() came with Backslash 0.4; older launchers only copy. const pasteFirst = (text: string) => ("paste" in api ? [api.paste(text, "Paste Emoji")] : []); @@ -10,27 +10,65 @@ import data from "../emoji.json" with { type: "json" }; const official = (name: string) => name.split(" · ").pop()!; const title = (name: string) => official(name).replace(/\b\w/g, (c) => c.toUpperCase()); +/** Emoji picked recently, most recent first. */ +async function recent(): Promise { + return ((await storage.get("recent").catch(() => undefined)) ?? []).slice(0, 16); +} + +async function remember(emoji: string) { + const list = (await recent()).filter((e) => e !== emoji); + await storage.set("recent", [emoji, ...list].slice(0, 16)); +} + +/** Runs `then` after remembering the emoji, for the "Recently used" row. */ +const remembering = (emoji: string, actions: api.Action[]): api.Action[] => + actions.map((a) => + a.title.startsWith("Paste") || a.title === "Copy Emoji" + ? api.action(a.title, async () => { + await remember(emoji); + if (a.title.startsWith("Paste")) await api.clipboard.paste(emoji); + else { + await api.clipboard.write(emoji); + return api.close("Copied"); + } + }, { icon: a.icon, shortcut: a.shortcut }) + : a + ); + +const all = (data as [string, string][]).map(([emoji, name]) => { + const codepoints = [...emoji] + .filter((c) => c !== "️") + .map((c) => `U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`) + .join(" "); + return { + id: codepoints, + title: title(name), + subtitle: codepoints, + icon: emoji, + section: "All", + keywords: name.includes(" · ") ? [name.split(" · ")[0], ...name.split(" ")] : name.split(" "), + actions: remembering(emoji, [ + ...pasteFirst(emoji), + copy(emoji, "Copy Emoji"), + copy(title(name), "Copy Name"), + copy(codepoints, "Copy Code Point"), + ]), + }; +}); + export default list({ placeholder: "Search emoji and symbols…", layout: "grid", columns: 8, - items: (data as [string, string][]).map(([emoji, name]) => { - const codepoints = [...emoji] - .filter((c) => c !== "️") - .map((c) => `U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`) - .join(" "); - return { - id: codepoints, - title: title(name), - subtitle: codepoints, - icon: emoji, - keywords: name.includes(" · ") ? [name.split(" · ")[0], ...name.split(" ")] : name.split(" "), - actions: [ - ...pasteFirst(emoji), - copy(emoji, "Copy Emoji"), - copy(title(name), "Copy Name"), - copy(codepoints, "Copy Code Point"), - ], - }; - }), + status: (items) => `${items.length} emoji`, + items: async () => { + const used = await recent(); + const byEmoji = new Map(all.map((i) => [i.icon, i])); + const top = used.map((e) => byEmoji.get(e)).filter((i) => i !== undefined).map((i) => ({ + ...i!, + id: `recent:${i!.id}`, + section: "Recently used", + })); + return [...top, ...all]; + }, }); diff --git a/extensions/github/api.ts b/extensions/github/api.ts new file mode 100644 index 0000000..1a2f711 --- /dev/null +++ b/extensions/github/api.ts @@ -0,0 +1,146 @@ +// GitHub REST and GraphQL helpers shared by the commands. + +import { type Color, exec } from "@backslash/api"; + +const HEADERS = { "User-Agent": "backslash-launcher", "X-GitHub-Api-Version": "2022-11-28" }; + +/** The token from the preferences, else the GitHub CLI's (`gh auth token`), if any. */ +export async function token(preferences: Record): Promise { + const saved = (preferences.token as string | undefined)?.trim(); + if (saved) return saved; + try { + return (await exec("gh", ["auth", "token"])) || undefined; + } catch { + return undefined; + } +} + +export async function needToken(preferences: Record): Promise { + const t = await token(preferences); + if (!t) { + throw new Error("Sign in with `gh auth login`, or add a token in Settings → Extensions → GitHub"); + } + return t; +} + +function check(res: Response) { + if (res.status === 401) throw new Error("GitHub rejected the token: check it in Settings → Extensions → GitHub"); + if (res.status === 403 || res.status === 429) { + if (res.headers.get("x-ratelimit-remaining") === "0") { + throw new Error("GitHub rate limit reached: add a token in Settings → Extensions → GitHub"); + } + throw new Error(`GitHub refused the request (${res.status})`); + } + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); +} + +export async function github(path: string, token: string | undefined, signal?: AbortSignal): Promise { + const res = await fetch(`https://api.github.com${path}`, { + signal, + headers: { + ...HEADERS, + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + check(res); + return await res.json() as T; +} + +export async function graphql( + token: string, + query: string, + variables: Record = {}, + signal?: AbortSignal, +): Promise { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + signal, + headers: { ...HEADERS, "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + check(res); + const body = await res.json() as { data?: T; errors?: { message: string }[] }; + if (body.errors?.length) throw new Error(`GitHub: ${body.errors[0].message}`); + return body.data as T; +} + +export async function readme(fullName: string, token: string | undefined): Promise { + const res = await fetch(`https://api.github.com/repos/${fullName}/readme`, { + headers: { + ...HEADERS, + Accept: "application/vnd.github.raw", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + return res.ok ? await res.text() : "_No README._"; +} + +/** "today", "yesterday", "3d", "25 Oct". */ +export function ago(iso: string, now = new Date()): string { + const d = new Date(iso); + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (days < 365) return d.toLocaleDateString("en", { day: "numeric", month: "short" }); + return d.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +/** 1234 → "1.2k". */ +export function compact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`; + return String(n); +} + +/** The palette color closest to a GitHub color (labels, languages). */ +export function colorOf(hex: string | null | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +/** Colors GitHub gives the most common languages. */ +const LANGUAGES: Record = { + Rust: "#dea584", + TypeScript: "#3178c6", + JavaScript: "#f1e05a", + Python: "#3572a5", + Go: "#00add8", + C: "#555555", + "C++": "#f34b7d", + Java: "#b07219", + Kotlin: "#a97bff", + Swift: "#f05138", + Ruby: "#701516", + PHP: "#4f5d95", + Shell: "#89e051", + Lua: "#000080", + Zig: "#ec915c", + Nix: "#7e7eff", + HTML: "#e34c26", + CSS: "#563d7c", + Vue: "#41b883", + Dart: "#00b4ab", + Elixir: "#6e4a7e", + Haskell: "#5e5086", + "C#": "#178600", +}; + +export const languageColor = (language: string | null | undefined): Color => colorOf(LANGUAGES[language ?? ""]); diff --git a/extensions/github/backslash.toml b/extensions/github/backslash.toml index 2cd88d8..16c7b6a 100644 --- a/extensions/github/backslash.toml +++ b/extensions/github/backslash.toml @@ -1,14 +1,16 @@ [extension] id = "github" title = "GitHub" -description = "Search repositories and follow your pull requests" -version = "1.0.0" +description = "Pull requests with their checks and reviews, your issues, and repository search" +version = "1.1.0" author = "Backslash" -icon = "folder-remote" +icon = "bs-repo" license = "MIT" [permissions] -net = ["api.github.com"] +net = ["api.github.com", "avatars.githubusercontent.com"] +# Uses the GitHub CLI's sign-in when no token is set. +run = ["gh"] [[commands]] name = "repositories" @@ -18,11 +20,17 @@ keywords = ["github", "repo", "git", "code"] [[commands]] name = "pull-requests" title = "My Pull Requests" -description = "Pull requests you opened and those waiting for your review" -keywords = ["github", "pr", "review"] +description = "To review, opened by you, assigned: with checks and reviews" +keywords = ["github", "pr", "review", "ci"] + +[[commands]] +name = "issues" +title = "My GitHub Issues" +description = "Assigned to you, opened by you, mentioning you" +keywords = ["github", "issues", "bugs", "tickets"] [[preferences]] name = "token" title = "Personal access token" -description = "Needed for your pull requests; raises the search rate limit. Read-only scopes are enough." +description = "Optional when the GitHub CLI is signed in (gh auth login). Read-only scopes are enough." type = "secret" diff --git a/extensions/github/commands/api.ts b/extensions/github/commands/api.ts deleted file mode 100644 index 48db898..0000000 --- a/extensions/github/commands/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Small GitHub REST helper shared by the commands. - -export async function github(path: string, token: string | undefined, signal?: AbortSignal): Promise { - const res = await fetch(`https://api.github.com${path}`, { - signal, - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "backslash-launcher", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); - if (res.status === 401) throw new Error("GitHub rejected the token: check it in Settings → Extensions → GitHub"); - if (res.status === 403 || res.status === 429) { - if (res.headers.get("x-ratelimit-remaining") === "0") { - throw new Error("GitHub rate limit reached: add a token in Settings → Extensions → GitHub"); - } - throw new Error(`GitHub refused the request (${res.status})`); - } - if (!res.ok) throw new Error(`GitHub answered ${res.status}`); - return await res.json() as T; -} - -export async function readme(fullName: string, token: string | undefined): Promise { - const res = await fetch(`https://api.github.com/repos/${fullName}/readme`, { - headers: { - Accept: "application/vnd.github.raw", - "User-Agent": "backslash-launcher", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); - return res.ok ? await res.text() : "_No README._"; -} - -export function ago(iso: string): string { - const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000); - if (days <= 0) return "today"; - if (days === 1) return "yesterday"; - if (days < 30) return `${days} days ago`; - return new Date(iso).toLocaleDateString(); -} diff --git a/extensions/github/commands/issues.ts b/extensions/github/commands/issues.ts new file mode 100644 index 0000000..675b53d --- /dev/null +++ b/extensions/github/commands/issues.ts @@ -0,0 +1,120 @@ +import { action, type Context, copy, detail, Icons, list, meta, open } from "@backslash/api"; +import { ago, colorOf, graphql, needToken } from "../api.ts"; + +interface Issue { + id: string; + number: number; + title: string; + url: string; + body?: string | null; + state: "OPEN" | "CLOSED"; + createdAt: string; + updatedAt: string; + author?: { login: string; avatarUrl?: string } | null; + repository: { nameWithOwner: string }; + labels?: { nodes: { name: string; color: string }[] }; + assignees?: { nodes: { login: string; avatarUrl?: string }[] }; + comments?: { totalCount: number }; + milestone?: { title: string } | null; +} + +const FIELDS = ` + ... on Issue { + id number title url body state createdAt updatedAt + author { login avatarUrl } + repository { nameWithOwner } + labels(first: 6) { nodes { name color } } + assignees(first: 4) { nodes { login avatarUrl } } + comments { totalCount } + milestone { title } + } +`; + +const QUERIES: Record = { + assigned: "is:issue is:open archived:false assignee:@me", + created: "is:issue is:open archived:false author:@me", + mentioned: "is:issue is:open archived:false mentions:@me", +}; + +function metadata(issue: Issue) { + const labels = issue.labels?.nodes ?? []; + return [ + meta.text("Status", issue.state === "OPEN" ? "Open" : "Closed", { + icon: issue.state === "OPEN" ? Icons.issue : Icons.issueClosed, + color: issue.state === "OPEN" ? "green" : "purple", + }), + meta.person("Author", issue.author?.login ?? "ghost", issue.author?.avatarUrl), + ...(issue.assignees?.nodes.length ? [meta.tags("Assignees", issue.assignees.nodes.map((a) => a.login))] : []), + ...(labels.length ? [meta.tags("Labels", labels.map((l) => ({ text: l.name, color: colorOf(l.color) })))] : []), + ...(issue.milestone ? [meta.text("Milestone", issue.milestone.title, { icon: Icons.flag })] : []), + meta.text("Comments", issue.comments?.totalCount ?? 0, { icon: Icons.comment }), + meta.separator(), + meta.text("Repository", issue.repository.nameWithOwner, { icon: Icons.repository }), + meta.text("Opened", ago(issue.createdAt)), + meta.text("Updated", ago(issue.updatedAt)), + ]; +} + +export default (_ctx: Context) => + list({ + placeholder: "Filter issues by title, repository, label…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "created", title: "Opened by me" }, + { value: "mentioned", title: "Mentioning me" }, + ], + }, + empty: { title: "No open issues here", icon: "bs-issue-closed" }, + status: (items) => `${items.length} issue${items.length === 1 ? "" : "s"}`, + async items({ preferences, signal, filter }) { + const t = await needToken(preferences); + const data = await graphql<{ search: { nodes: Issue[] } }>( + t, + `query($q: String!) { search(query: $q, type: ISSUE, first: 50) { nodes { ${FIELDS} } } }`, + { q: `${QUERIES[filter ?? "assigned"] ?? QUERIES.assigned} sort:updated-desc` }, + signal, + ); + return data.search.nodes.filter((n) => n && n.number).map((issue) => { + const labels = issue.labels?.nodes ?? []; + const body = issue.body?.trim(); + return { + id: issue.id, + title: issue.title, + subtitle: `#${issue.number}`, + icon: { source: Icons.issue, tint: "green" as const }, + section: issue.repository.nameWithOwner, + keywords: [issue.repository.nameWithOwner, String(issue.number), ...labels.map((l) => l.name)], + accessories: [ + ...labels.slice(0, 2).map((l) => ({ tag: l.name, color: colorOf(l.color) })), + ...(issue.comments?.totalCount ? [{ text: String(issue.comments.totalCount), icon: Icons.comment }] : []), + ...(issue.author + ? [{ avatar: issue.author.avatarUrl ?? issue.author.login, tooltip: issue.author.login }] + : []), + { text: ago(issue.updatedAt) }, + ], + detail: { + markdown: body ? (body.length > 400 ? `${body.slice(0, 400)}…` : body) : undefined, + metadata: metadata(issue), + }, + actions: [ + open(issue.url, "Open in Browser"), + action("Show Details", () => + detail({ + title: `#${issue.number}`, + markdown: `# ${issue.title}\n\n${body || "_No description._"}`, + metadata: metadata(issue), + status: `${issue.repository.nameWithOwner} #${issue.number}`, + actions: [open(issue.url, "Open in Browser"), copy(issue.url, "Copy URL")], + }), { icon: Icons.info }), + copy(issue.url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy( + `[${issue.repository.nameWithOwner}#${issue.number}: ${issue.title}](${issue.url})`, + "Copy as Markdown Link", + { section: "Copy" }, + ), + ], + }; + }); + }, + }); diff --git a/extensions/github/commands/pull-requests.ts b/extensions/github/commands/pull-requests.ts index e770add..6caea99 100644 --- a/extensions/github/commands/pull-requests.ts +++ b/extensions/github/commands/pull-requests.ts @@ -1,50 +1,40 @@ -import { copy, type Item, list, open } from "@backslash/api"; -import { ago, github } from "./api.ts"; +import { type Context, list, updateCommandMetadata } from "@backslash/api"; +import { graphql, needToken } from "../api.ts"; +import { PR_FIELDS, prItem, type PullRequest } from "../prs.ts"; -interface Issue { - number: number; - title: string; - html_url: string; - updated_at: string; - draft?: boolean; - repository_url: string; - user: { login: string }; -} +const QUERIES: Record = { + review: "is:pr is:open archived:false review-requested:@me", + mine: "is:pr is:open archived:false author:@me", + assigned: "is:pr is:open archived:false assignee:@me", + merged: "is:pr is:merged author:@me", +}; -function item(pr: Issue, section: string): Item { - const repo = pr.repository_url.replace("https://api.github.com/repos/", ""); - return { - id: pr.html_url, - title: pr.title, - subtitle: `${repo} #${pr.number} · ${ago(pr.updated_at)}`, - icon: "mail-forward", - section, - keywords: [repo, pr.user.login, String(pr.number)], - accessories: pr.draft ? [{ tag: "Draft", color: "gray" }] : [], - actions: [open(pr.html_url, "Open in Browser"), copy(pr.html_url, "Copy URL")], - }; -} - -export default list({ - placeholder: "Filter pull requests…", - empty: { - title: "No open pull requests", - description: "Nothing waits for you. (A token is needed: Settings → Extensions → GitHub.)", - }, - async items({ preferences, signal }) { - const token = preferences.token as string | undefined; - if (!token) { - throw new Error("Add a GitHub token in Settings → Extensions → GitHub to see your pull requests"); - } - const search = (q: string) => - github<{ items: Issue[] }>(`/search/issues?q=${encodeURIComponent(q)}&per_page=50`, token, signal); - const [review, mine] = await Promise.all([ - search("is:pr is:open review-requested:@me archived:false"), - search("is:pr is:open author:@me archived:false"), - ]); - return [ - ...review.items.map((pr) => item(pr, "Waiting for your review")), - ...mine.items.map((pr) => item(pr, "Opened by you")), - ]; - }, -}); +export default (ctx: Context) => + list({ + placeholder: "Filter pull requests by title, repository, author, branch…", + filter: { + options: [ + { value: "review", title: "To review" }, + { value: "mine", title: "Opened by me" }, + { value: "assigned", title: "Assigned" }, + { value: "merged", title: "Recently merged" }, + ], + }, + empty: { title: "Nothing here", description: "No pull request matches.", icon: "bs-pr-open" }, + status: (items) => `${items.length} pull request${items.length === 1 ? "" : "s"}`, + async items({ preferences, signal, filter }) { + const key = filter ?? "review"; + const t = await needToken(preferences); + const data = await graphql<{ search: { nodes: PullRequest[] } }>( + t, + `query($q: String!) { search(query: $q, type: ISSUE, first: 40) { nodes { ${PR_FIELDS} } } }`, + { q: `${QUERIES[key] ?? QUERIES.review} sort:updated-desc` }, + signal, + ); + const prs = data.search.nodes.filter((n) => n && n.number); + if (key === "review") { + updateCommandMetadata({ subtitle: prs.length ? `${prs.length} to review` : null }).catch(() => {}); + } + return prs.map((pr) => prItem(pr, ctx, pr.repository.nameWithOwner)); + }, + }); diff --git a/extensions/github/commands/repositories.ts b/extensions/github/commands/repositories.ts index 133facc..5d9e39d 100644 --- a/extensions/github/commands/repositories.ts +++ b/extensions/github/commands/repositories.ts @@ -1,42 +1,91 @@ -import { action, copy, detail, list, open } from "@backslash/api"; -import { github, readme } from "./api.ts"; +import { action, type Context, copy, detail, Icons, type Item, list, meta, open } from "@backslash/api"; +import { ago, compact, github, languageColor, readme, token } from "../api.ts"; interface Repo { full_name: string; + name: string; description: string | null; html_url: string; clone_url: string; + ssh_url: string; stargazers_count: number; + forks_count: number; + open_issues_count: number; language: string | null; + topics?: string[]; + license?: { spdx_id: string } | null; + default_branch: string; + pushed_at: string; updated_at: string; - owner: { login: string }; + archived?: boolean; + private?: boolean; + fork?: boolean; + owner: { login: string; avatar_url: string }; } -const stars = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k ★` : `${n} ★`); - -export default list({ - placeholder: "Search repositories…", - empty: { title: "Search GitHub", description: "Type a name, a topic, or `language:rust tui`" }, - async search(query, { signal, preferences }) { - if (!query.trim()) return []; - const token = preferences.token as string | undefined; - const q = encodeURIComponent(query); - const { items } = await github<{ items: Repo[] }>(`/search/repositories?q=${q}&per_page=25`, token, signal); - return items.map((r) => ({ - id: r.full_name, - title: r.full_name, - subtitle: r.description ?? undefined, - icon: "folder-remote", - accessories: [ - ...(r.language ? [{ text: r.language }] : []), - { tag: stars(r.stargazers_count) }, +function item(r: Repo, t: string | undefined, section?: string): Item { + return { + id: r.full_name, + title: r.full_name, + subtitle: r.description ?? undefined, + icon: r.owner.avatar_url, + section, + keywords: [r.language ?? "", ...(r.topics ?? [])], + accessories: [ + ...(r.private ? [{ icon: Icons.lock, tooltip: "Private" }] : []), + ...(r.archived ? [{ tag: "Archived", color: "gray" as const }] : []), + ...(r.language ? [{ icon: { source: Icons.dot, tint: languageColor(r.language) }, text: r.language }] : []), + { icon: Icons.star, text: compact(r.stargazers_count), tooltip: `${r.stargazers_count} stars` }, + { text: ago(r.pushed_at), tooltip: "Last push" }, + ], + detail: { + markdown: r.description ?? undefined, + stats: [ + { label: "stars", value: compact(r.stargazers_count) }, + { label: "forks", value: compact(r.forks_count) }, + { label: "open issues", value: compact(r.open_issues_count) }, ], - actions: [ - open(r.html_url, "Open in Browser"), - action("Show README", async () => detail({ title: r.full_name, markdown: await readme(r.full_name, token) })), - copy(`git clone ${r.clone_url}`, "Copy Clone Command", { shortcut: "c" }), - copy(r.html_url, "Copy URL"), + metadata: [ + ...(r.language + ? [meta.text("Language", r.language, { icon: Icons.dot, color: languageColor(r.language) })] + : []), + ...(r.license?.spdx_id && r.license.spdx_id !== "NOASSERTION" ? [meta.text("License", r.license.spdx_id)] : []), + ...(r.topics?.length ? [meta.tags("Topics", r.topics.slice(0, 8))] : []), + meta.text("Branch", r.default_branch, { icon: Icons.branch }), + meta.separator(), + meta.person("Owner", r.owner.login, r.owner.avatar_url), + meta.text("Last push", ago(r.pushed_at)), + meta.link("Page", r.full_name, r.html_url), ], - })); - }, -}); + }, + actions: [ + open(r.html_url, "Open in Browser"), + action("Show README", async () => detail({ title: r.name, markdown: await readme(r.full_name, t) }), { + icon: Icons.file, + }), + open(`${r.html_url}/issues`, "Open Issues", { icon: Icons.issue }), + open(`${r.html_url}/pulls`, "Open Pull Requests", { icon: Icons.pullRequest }), + copy(`git clone ${r.clone_url}`, "Copy Clone Command (HTTPS)", { shortcut: "c", section: "Copy" }), + copy(`git clone ${r.ssh_url}`, "Copy Clone Command (SSH)", { section: "Copy" }), + copy(r.html_url, "Copy URL", { section: "Copy" }), + ], + }; +} + +export default (_ctx: Context) => + list({ + placeholder: "Search repositories, or browse yours…", + empty: { title: "Search GitHub", description: "Type a name, a topic, or `language:rust tui`", icon: "bs-search" }, + status: (items) => `${items.length} repositor${items.length === 1 ? "y" : "ies"}`, + async search(query, { signal, preferences }) { + const t = await token(preferences); + if (!query.trim()) { + if (!t) return []; + const mine = await github("/user/repos?sort=pushed&per_page=30", t, signal); + return mine.map((r) => item(r, t, "Recently pushed")); + } + const q = encodeURIComponent(query); + const { items } = await github<{ items: Repo[] }>(`/search/repositories?q=${q}&per_page=25`, t, signal); + return items.map((r) => item(r, t)); + }, + }); diff --git a/extensions/github/github_test.ts b/extensions/github/github_test.ts new file mode 100644 index 0000000..e450e05 --- /dev/null +++ b/extensions/github/github_test.ts @@ -0,0 +1,70 @@ +import { assertEquals } from "@std/assert"; +import type { Context } from "@backslash/api"; +import { ago, colorOf, compact, languageColor } from "./api.ts"; +import { checks, prItem, type PullRequest, review, stateIcon } from "./prs.ts"; + +const ctx: Context = { args: {}, preferences: {}, query: "", signal: new AbortController().signal }; + +const pr: PullRequest = { + id: "PR_1", + number: 57, + title: "Detect terminals before pasting", + url: "https://github.com/backslash-app/backslash/pull/57", + body: "Fixes pasting into Kitty.", + isDraft: false, + state: "OPEN", + createdAt: "2026-10-27T08:00:00Z", + updatedAt: "2026-10-27T10:00:00Z", + additions: 86, + deletions: 12, + changedFiles: 4, + headRefName: "fix/kitty-paste", + baseRefName: "main", + reviewDecision: "APPROVED", + author: { login: "ada", avatarUrl: "https://avatars.githubusercontent.com/u/1" }, + repository: { nameWithOwner: "backslash-app/backslash" }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + commits: { totalCount: 3, nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, +}; + +Deno.test("numbers, dates and colors", () => { + assertEquals([compact(950), compact(1234), compact(45_600), compact(2_300_000)], ["950", "1.2k", "46k", "2.3M"]); + const now = new Date("2026-10-27T12:00:00Z"); + assertEquals([ago("2026-10-27T09:00:00Z", now), ago("2026-10-26T09:00:00Z", now), ago("2026-10-22T09:00:00Z", now)], [ + "today", + "yesterday", + "5d", + ]); + assertEquals([colorOf("d73a4a"), colorOf("0e8a16"), languageColor("Rust"), languageColor("Nope")], [ + "red", + "green", + "orange", + "gray", + ]); +}); + +Deno.test("pull request states, checks and reviews", () => { + assertEquals(stateIcon(pr), { source: "bs-pr-open", tint: "green" }); + assertEquals(stateIcon({ ...pr, isDraft: true }).tint, "gray"); + assertEquals(stateIcon({ ...pr, state: "MERGED" }).source, "bs-pr-merged"); + assertEquals(checks(pr)?.color, "green"); + assertEquals( + checks({ ...pr, commits: { totalCount: 1, nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] } }) + ?.text, + "Checks failed", + ); + assertEquals(review(pr), { text: "Approved", color: "green" }); +}); + +Deno.test("pull request item", () => { + const item = prItem(pr, ctx, "backslash-app/backslash"); + assertEquals(item.subtitle, "#57"); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text), [undefined, "Approved", "+86 −12", undefined, "today"]); + assertEquals(item.detail?.markdown, "Fixes pasting into Kitty."); + assertEquals(item.detail?.stats?.map((s) => s.value), [4, "+86", "−12"]); + assertEquals(item.actions?.slice(0, 3).map((a) => a.title), [ + "Open in Browser", + "Show Details", + "Open Changed Files", + ]); +}); diff --git a/extensions/github/prs.ts b/extensions/github/prs.ts new file mode 100644 index 0000000..1221ff4 --- /dev/null +++ b/extensions/github/prs.ts @@ -0,0 +1,189 @@ +// Pull requests: GraphQL fields, and how they look in the list and the panel. + +import { + type Action, + action, + type Color, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, +} from "@backslash/api"; +import { ago, colorOf } from "./api.ts"; + +export const PR_FIELDS = ` + ... on PullRequest { + id number title url body isDraft state createdAt updatedAt + additions deletions changedFiles + headRefName baseRefName mergeable reviewDecision + author { login avatarUrl } + repository { nameWithOwner } + labels(first: 6) { nodes { name color } } + commits(last: 1) { totalCount nodes { commit { statusCheckRollup { state } } } } + reviewRequests(first: 6) { nodes { requestedReviewer { ... on User { login avatarUrl } ... on Team { name } } } } + comments { totalCount } + } +`; + +export interface PullRequest { + id: string; + number: number; + title: string; + url: string; + body?: string | null; + isDraft: boolean; + state: "OPEN" | "CLOSED" | "MERGED"; + createdAt: string; + updatedAt: string; + additions: number; + deletions: number; + changedFiles: number; + headRefName: string; + baseRefName: string; + mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; + reviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; + author?: { login: string; avatarUrl?: string } | null; + repository: { nameWithOwner: string }; + labels?: { nodes: { name: string; color: string }[] }; + commits?: { totalCount: number; nodes: { commit: { statusCheckRollup?: { state: string } | null } }[] }; + reviewRequests?: { nodes: { requestedReviewer?: { login?: string; avatarUrl?: string; name?: string } | null }[] }; + comments?: { totalCount: number }; +} + +export function stateIcon(pr: PullRequest): { source: string; tint: Color } { + if (pr.state === "MERGED") return { source: Icons.merged, tint: "purple" }; + if (pr.state === "CLOSED") return { source: Icons.pullRequestClosed, tint: "red" }; + if (pr.isDraft) return { source: Icons.pullRequestDraft, tint: "gray" }; + return { source: Icons.pullRequest, tint: "green" }; +} + +/** CI of the last commit: icon, color, words. */ +export function checks(pr: PullRequest): { icon: string; color: Color; text: string } | undefined { + const state = pr.commits?.nodes[0]?.commit.statusCheckRollup?.state; + switch (state) { + case "SUCCESS": + return { icon: Icons.check, color: "green", text: "Checks passed" }; + case "FAILURE": + case "ERROR": + return { icon: Icons.cross, color: "red", text: "Checks failed" }; + case "PENDING": + case "EXPECTED": + return { icon: Icons.dot, color: "yellow", text: "Checks running" }; + default: + return undefined; + } +} + +export function review(pr: PullRequest): { text: string; color: Color } | undefined { + switch (pr.reviewDecision) { + case "APPROVED": + return { text: "Approved", color: "green" }; + case "CHANGES_REQUESTED": + return { text: "Changes requested", color: "red" }; + case "REVIEW_REQUIRED": + return { text: "Review required", color: "orange" }; + default: + return undefined; + } +} + +function stateText(pr: PullRequest): string { + if (pr.state === "MERGED") return "Merged"; + if (pr.state === "CLOSED") return "Closed"; + return pr.isDraft ? "Draft" : "Open"; +} + +export function prMetadata(pr: PullRequest): Metadata[] { + const s = stateIcon(pr); + const ci = checks(pr); + const r = review(pr); + const lines: Metadata[] = [ + meta.text("Status", stateText(pr), { icon: s.source, color: s.tint }), + meta.person("Author", pr.author?.login ?? "ghost", pr.author?.avatarUrl), + meta.text("Branch", `${pr.headRefName} → ${pr.baseRefName}`, { icon: Icons.branch }), + ]; + if (ci) lines.push(meta.text("Checks", ci.text, { icon: ci.icon, color: ci.color })); + if (r) lines.push(meta.text("Review", r.text, { color: r.color })); + if (pr.mergeable === "CONFLICTING") { + lines.push(meta.text("Merge", "Conflicts with the base branch", { icon: Icons.alert, color: "red" })); + } + const reviewers = (pr.reviewRequests?.nodes ?? []) + .map((n) => n.requestedReviewer?.login ?? n.requestedReviewer?.name) + .filter((n): n is string => !!n); + if (reviewers.length) lines.push(meta.tags("Reviewers", reviewers)); + const labels = pr.labels?.nodes ?? []; + if (labels.length) lines.push(meta.tags("Labels", labels.map((l) => ({ text: l.name, color: colorOf(l.color) })))); + lines.push(meta.separator()); + lines.push(meta.text("Repository", pr.repository.nameWithOwner, { icon: Icons.repository })); + lines.push(meta.text("Opened", ago(pr.createdAt))); + lines.push(meta.text("Updated", ago(pr.updatedAt))); + return lines; +} + +const stats = (pr: PullRequest) => [ + { label: "files", value: pr.changedFiles }, + { label: "added", value: `+${pr.additions}`, color: "green" as Color }, + { label: "removed", value: `−${pr.deletions}`, color: "red" as Color }, +]; + +function excerpt(text: string | null | undefined, max = 400): string | undefined { + const t = (text ?? "").replace(//g, "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +export function prActions(pr: PullRequest, ctx: Context, page = false): Action[] { + return [ + open(pr.url, "Open in Browser"), + ...(page ? [] : [action("Show Details", () => prPage(pr, ctx), { icon: Icons.info })]), + open(`${pr.url}/files`, "Open Changed Files", { icon: Icons.code, shortcut: "f" }), + copy(pr.url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`gh pr checkout ${pr.number} -R ${pr.repository.nameWithOwner}`, "Copy Checkout Command", { + icon: Icons.terminal, + section: "Copy", + }), + copy(pr.headRefName, "Copy Branch Name", { icon: Icons.branch, section: "Copy" }), + copy(`[${pr.repository.nameWithOwner}#${pr.number}: ${pr.title}](${pr.url})`, "Copy as Markdown Link", { + section: "Copy", + }), + ]; +} + +export function prPage(pr: PullRequest, ctx: Context): DetailSpec { + return detail({ + title: `#${pr.number}`, + markdown: `# ${pr.title}\n\n${pr.body?.replace(//g, "").trim() || "_No description._"}`, + stats: stats(pr), + metadata: prMetadata(pr), + status: `${pr.repository.nameWithOwner} #${pr.number}`, + actions: prActions(pr, ctx, true), + }); +} + +export function prItem(pr: PullRequest, ctx: Context, section?: string): Item { + const ci = checks(pr); + const r = review(pr); + return { + id: pr.id, + title: pr.title, + subtitle: section ? `#${pr.number}` : `${pr.repository.nameWithOwner.split("/")[1]} #${pr.number}`, + icon: stateIcon(pr), + section, + keywords: [pr.repository.nameWithOwner, String(pr.number), pr.author?.login ?? "", pr.headRefName], + accessories: [ + ...(ci ? [{ icon: { source: ci.icon, tint: ci.color }, tooltip: ci.text }] : []), + // "Review required" is the usual case: only decisions stand out. + ...(r && pr.reviewDecision !== "REVIEW_REQUIRED" ? [{ tag: r.text, color: r.color }] : []), + { text: `+${pr.additions} −${pr.deletions}`, tooltip: `${pr.changedFiles} files` }, + ...(pr.author ? [{ avatar: pr.author.avatarUrl ?? pr.author.login, tooltip: pr.author.login }] : []), + { text: ago(pr.updatedAt), tooltip: "Updated" }, + ], + detail: { stats: stats(pr), markdown: excerpt(pr.body), metadata: prMetadata(pr) }, + actions: prActions(pr, ctx), + }; +} diff --git a/extensions/linear/backslash.toml b/extensions/linear/backslash.toml index add745f..e250a52 100644 --- a/extensions/linear/backslash.toml +++ b/extensions/linear/backslash.toml @@ -1,19 +1,19 @@ [extension] id = "linear" title = "Linear" -description = "Your issues, search and quick issue creation for Linear" -version = "1.0.0" +description = "Your issues with their status, priority, labels and assignee; search, triage and quick issue creation" +version = "1.1.0" author = "Backslash" -icon = "📐" +icon = "bs-circle-half" license = "MIT" [permissions] -net = ["api.linear.app"] +net = ["api.linear.app", "public.linear.app"] [[commands]] name = "my-issues" title = "My Linear Issues" -description = "Issues assigned to you, by status" +description = "Assigned, created or subscribed issues, by status" keywords = ["linear", "issues", "tickets", "tasks", "todo"] [[commands]] diff --git a/extensions/linear/commands/create-issue.ts b/extensions/linear/commands/create-issue.ts index 739d603..1b39cb4 100644 --- a/extensions/linear/commands/create-issue.ts +++ b/extensions/linear/commands/create-issue.ts @@ -1,4 +1,4 @@ -import { action, close, type Context, copy, detail, field, form, open } from "@backslash/api"; +import { action, close, type Context, copy, detail, field, form, meta, open } from "@backslash/api"; import { linear, PRIORITIES } from "../lib.ts"; interface Team { @@ -47,8 +47,20 @@ export default async ({ preferences }: Context) => { const { identifier, url } = issueCreate.issue; return detail({ title: identifier, - markdown: `# ${identifier} created\n\n${String(values.title)}`, - actions: [open(url, "Open in Linear"), copy(url, "Copy URL"), action("Done", () => close())], + markdown: `# ${String(values.title)}\n\nCreated in Linear as **${identifier}**.`, + metadata: [ + meta.text("Identifier", identifier), + meta.text("Team", teams.nodes.find((t) => t.id === values.team)?.name ?? ""), + meta.text("Priority", PRIORITIES.find((p) => p.value === values.priority)?.title ?? ""), + meta.link("Link", "Open in Linear", url), + ], + status: "Issue created", + actions: [ + open(url, "Open in Linear"), + copy(identifier, "Copy Identifier", { shortcut: "period" }), + copy(url, "Copy URL", { shortcut: "c" }), + action("Done", () => close()), + ], }); }, }); diff --git a/extensions/linear/commands/my-issues.ts b/extensions/linear/commands/my-issues.ts index 28256b6..af9be5f 100644 --- a/extensions/linear/commands/my-issues.ts +++ b/extensions/linear/commands/my-issues.ts @@ -1,25 +1,42 @@ -import { type Context, list } from "@backslash/api"; +import { type Context, list, updateCommandMetadata } from "@backslash/api"; import { type Issue, ISSUE_FIELDS, issueItem, linear, live, sortIssues } from "../lib.ts"; -import { statusPicker } from "../statuses.ts"; -const QUERY = `query { viewer { assignedIssues(first: 100, orderBy: updatedAt, - filter: { state: { type: { nin: ["completed", "canceled"] } } }) { nodes { ${ISSUE_FIELDS} } } } }`; +const FILTERS = { + assigned: "{ assignee: { isMe: { eq: true } } }", + created: "{ creator: { isMe: { eq: true } } }", + subscribed: "{ subscribers: { isMe: { eq: true } } }", +}; + +const open = `state: { type: { nin: ["completed", "canceled"] } }`; export default (ctx: Context) => list({ - placeholder: "Filter your issues…", - empty: { title: "No open issues assigned to you", icon: "🎉" }, - // Reloads after a status change made from this list. - items: ({ preferences, signal }) => + placeholder: "Filter issues by title, identifier, label, project…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "created", title: "Created by me" }, + { value: "subscribed", title: "Subscribed" }, + ], + }, + empty: { title: "No open issues here", icon: "bs-circle-check" }, + status: (items) => `${items.length} open issue${items.length === 1 ? "" : "s"}`, + // Reloads after a change made from this list (status, priority…). + items: ({ preferences, signal, filter }) => live(async () => { - const data = await linear<{ viewer: { assignedIssues: { nodes: Issue[] } } }>( + const who = FILTERS[(filter ?? "assigned") as keyof typeof FILTERS] ?? FILTERS.assigned; + const where = who.replace(/\}$/, `, ${open} }`); + const data = await linear<{ issues: { nodes: Issue[] } }>( preferences.apiKey as string, - QUERY, + `query { issues(first: 100, orderBy: updatedAt, filter: ${where}) { nodes { ${ISSUE_FIELDS} } } }`, {}, signal, ); - return sortIssues(data.viewer.assignedIssues.nodes).map((issue) => - issueItem(issue, { section: issue.state.name, moveTo: (i) => statusPicker(i, ctx) }) - ); + const issues = sortIssues(data.issues.nodes); + if ((filter ?? "assigned") === "assigned") { + const started = issues.filter((i) => i.state.type === "started").length; + updateCommandMetadata({ subtitle: started ? `${started} in progress` : null }).catch(() => {}); + } + return issues.map((issue) => issueItem(issue, ctx, { section: issue.state.name })); }, signal), }); diff --git a/extensions/linear/commands/search-issues.ts b/extensions/linear/commands/search-issues.ts index bef4b7c..c6d0b28 100644 --- a/extensions/linear/commands/search-issues.ts +++ b/extensions/linear/commands/search-issues.ts @@ -1,11 +1,11 @@ import { type Context, list } from "@backslash/api"; import { type Issue, ISSUE_FIELDS, issueItem, linear } from "../lib.ts"; -import { statusPicker } from "../statuses.ts"; export default (ctx: Context) => list({ placeholder: "Search issues by title, identifier or text…", - empty: { title: "Type to search Linear" }, + empty: { title: "Type to search Linear", icon: "bs-search" }, + status: (items) => items.length ? `${items.length} result${items.length === 1 ? "" : "s"}` : "Linear", async search(query, { preferences, signal }) { if (query.trim().length < 2) return []; const data = await linear<{ searchIssues: { nodes: Issue[] } }>( @@ -14,6 +14,6 @@ export default (ctx: Context) => { term: query.trim() }, signal, ); - return data.searchIssues.nodes.map((issue) => issueItem(issue, { moveTo: (i) => statusPicker(i, ctx) })); + return data.searchIssues.nodes.map((issue) => issueItem(issue, ctx)); }, }); diff --git a/extensions/linear/lib.ts b/extensions/linear/lib.ts index 4f84c76..f9d2939 100644 --- a/extensions/linear/lib.ts +++ b/extensions/linear/lib.ts @@ -1,6 +1,21 @@ // Linear GraphQL helper and the mapping from issues to launcher items. -import { action, copy, type Item, open, type View } from "@backslash/api"; +import { + type Action, + action, + type Color, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + submenu, + toast, +} from "@backslash/api"; export const API = "https://api.linear.app/graphql"; @@ -25,11 +40,16 @@ export async function linear( } export const ISSUE_FIELDS = ` - id identifier title url priority priorityLabel updatedAt - state { id name type } + id identifier title url priority priorityLabel updatedAt createdAt dueDate estimate branchName + description + state { id name type color } team { id key name } project { name } - assignee { name } + cycle { number } + assignee { name avatarUrl } + creator { name } + labels { nodes { name color } } + children { nodes { state { type } } } `; export interface Issue { @@ -40,10 +60,19 @@ export interface Issue { priority: number; priorityLabel: string; updatedAt: string; - state: { id: string; name: string; type: string }; + createdAt?: string; + dueDate?: string | null; + estimate?: number | null; + branchName?: string; + description?: string | null; + state: { id: string; name: string; type: string; color?: string }; team: { id: string; key: string; name: string }; project?: { name: string } | null; - assignee?: { name: string } | null; + cycle?: { number: number } | null; + assignee?: { name: string; avatarUrl?: string | null } | null; + creator?: { name: string } | null; + labels?: { nodes: { name: string; color: string }[] }; + children?: { nodes: { state: { type: string } }[] }; } export interface WorkflowState { @@ -70,50 +99,118 @@ export function sortIssues(issues: Issue[]): Issue[] { ); } -const PRIORITY_COLOR: Record = { - 1: "red", - 2: "orange", - 3: "yellow", - 4: "blue", -}; - -const STATE_ICON: Record = { - started: "🟡", - unstarted: "⚪", - backlog: "◌", - triage: "🔶", - completed: "✅", - canceled: "⛔", -}; - -export function issueItem( - issue: Issue, - options: { section?: string; moveTo?: (issue: Issue) => Promise } = {}, -): Item { - const where = [issue.team.key, issue.project?.name].filter(Boolean).join(" · "); - const actions = [ - open(issue.url, "Open in Linear"), - copy(issue.identifier, "Copy Identifier", { shortcut: "period" }), - copy(issue.url, "Copy URL", { shortcut: "c" }), - copy(`[${issue.identifier}: ${issue.title}](${issue.url})`, "Copy as Markdown Link"), +/** The icon of a workflow state, colored like Linear does. */ +export function stateIcon(state: { name: string; type: string }): { source: string; tint: Color } { + switch (state.type) { + case "started": + return /review/i.test(state.name) + ? { source: Icons.inReview, tint: "green" } + : { source: Icons.inProgress, tint: "yellow" }; + case "unstarted": + return { source: Icons.todo, tint: "gray" }; + case "backlog": + return { source: Icons.backlog, tint: "gray" }; + case "triage": + return { source: Icons.alert, tint: "orange" }; + case "completed": + return { source: Icons.done, tint: "purple" }; + case "canceled": + return { source: Icons.canceled, tint: "gray" }; + default: + return { source: Icons.todo, tint: "gray" }; + } +} + +export function priorityIcon(priority: number): { source: string; tint: Color } { + switch (priority) { + case 1: + return { source: Icons.urgent, tint: "orange" }; + case 2: + return { source: Icons.high, tint: "gray" }; + case 3: + return { source: Icons.medium, tint: "gray" }; + case 4: + return { source: Icons.low, tint: "gray" }; + default: + return { source: Icons.noPriority, tint: "gray" }; + } +} + +/** The palette color closest to a Linear label color. */ +export function colorOf(hex: string | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +/** "today", "yesterday", "3d", "25 Oct". */ +export function shortDate(iso: string, now = new Date()): string { + const d = new Date(iso); + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0 && d.getDate() === now.getDate()) return "today"; + if (days <= 1) return "yesterday"; + if (days < 7) return `${days}d`; + return d.toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +/** Sub-issues done, out of all. */ +export function subIssues(issue: Issue): { done: number; total: number } { + const all = issue.children?.nodes ?? []; + return { done: all.filter((c) => c.state.type === "completed").length, total: all.length }; +} + +/** The side panel of an issue: its properties, like Linear's. */ +export function issueMetadata(issue: Issue): Metadata[] { + const state = stateIcon(issue.state); + const prio = priorityIcon(issue.priority); + const lines: Metadata[] = [ + meta.text("Status", issue.state.name, { icon: state.source, color: state.tint }), + meta.text("Priority", issue.priorityLabel, { + icon: prio.source, + color: issue.priority === 1 ? "orange" : undefined, + }), + issue.assignee + ? meta.person("Assignee", issue.assignee.name, issue.assignee.avatarUrl ?? undefined) + : meta.text("Assignee", "Unassigned", { icon: Icons.person }), ]; - if (options.moveTo) { - const moveTo = options.moveTo; - actions.splice(1, 0, action("Change Status…", () => moveTo(issue), { icon: "go-next", shortcut: "s" })); + const labels = issue.labels?.nodes ?? []; + if (labels.length) lines.push(meta.tags("Labels", labels.map((l) => ({ text: l.name, color: colorOf(l.color) })))); + if (issue.project) lines.push(meta.text("Project", issue.project.name, { icon: Icons.project })); + if (issue.cycle) lines.push(meta.text("Cycle", `Cycle ${issue.cycle.number}`, { icon: Icons.cycle })); + if (issue.estimate) lines.push(meta.text("Estimate", `${issue.estimate} point${issue.estimate > 1 ? "s" : ""}`)); + if (issue.dueDate) { + const overdue = new Date(issue.dueDate) < new Date(); + lines.push(meta.text("Due", issue.dueDate, { icon: Icons.calendar, color: overdue ? "red" : undefined })); } - return { - id: issue.id, - title: issue.title, - subtitle: where, - icon: STATE_ICON[issue.state.type] ?? "📐", - section: options.section, - keywords: [issue.identifier, issue.state.name, issue.team.name, issue.assignee?.name ?? ""].filter(Boolean), - accessories: [ - ...(issue.priority ? [{ tag: issue.priorityLabel, color: PRIORITY_COLOR[issue.priority] }] : []), - { text: issue.identifier }, - ], - actions, - }; + const subs = subIssues(issue); + if (subs.total) lines.push(meta.progress("Sub-issues", subs.done / subs.total, `${subs.done} of ${subs.total}`)); + lines.push(meta.separator()); + lines.push(meta.text("Team", issue.team.name)); + if (issue.creator) lines.push(meta.text("Created by", issue.creator.name)); + lines.push(meta.text("Updated", shortDate(issue.updatedAt))); + return lines; +} + +/** The first paragraphs of the description, for the side panel. */ +export function excerpt(markdown: string | null | undefined, max = 360): string | undefined { + const text = (markdown ?? "").trim(); + if (!text) return undefined; + return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text; } export const PRIORITIES = [ @@ -124,8 +221,8 @@ export const PRIORITIES = [ { value: "4", title: "Low" }, ]; -// Views of this extension run in one process: a change made in one view -// (a new status) reloads the lists still open under it. +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. const changes = new EventTarget(); export function notifyChanged() { @@ -147,3 +244,117 @@ export async function* live(load: () => Promise, signal: AbortSignal): Asy }); } } + +async function update(apiKey: string, issue: Issue, input: Record, message: string) { + const res = await linear<{ issueUpdate: { success: boolean } }>( + apiKey, + `mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`, + { id: issue.id, input }, + ); + if (!res.issueUpdate.success) throw new Error("Linear did not update the issue"); + notifyChanged(); + await toast(`${issue.identifier}: ${message}`, { style: "success" }); +} + +/** Everything you can do with an issue, grouped like in Linear. */ +export function issueActions(issue: Issue, ctx: Context, options: { details?: boolean } = {}): Action[] { + const apiKey = ctx.preferences.apiKey as string; + const statuses = submenu("Change Status", async () => { + const data = await linear<{ team: { states: { nodes: WorkflowState[] } } }>( + apiKey, + `query($id: String!) { team(id: $id) { states { nodes { id name type position } } } }`, + { id: issue.team.id }, + ); + return data.team.states.nodes + .sort((a, b) => stateRank(a.type) - stateRank(b.type) || a.position - b.position) + .map((s) => + action(s.name, () => update(apiKey, issue, { stateId: s.id }, s.name), { + icon: stateIcon(s).source, + }) + ); + }, { icon: Icons.inProgress, shortcut: "s", section: "Issue" }); + const priorities = submenu( + "Set Priority", + PRIORITIES.map((p) => + action(p.title, () => update(apiKey, issue, { priority: Number(p.value) }, p.title), { + icon: priorityIcon(Number(p.value)).source, + }) + ), + { icon: Icons.high, shortcut: "p", section: "Issue" }, + ); + const assignees = submenu("Assign To", async () => { + const data = await linear<{ viewer: { id: string }; team: { members: { nodes: { id: string; name: string }[] } } }>( + apiKey, + `query($id: String!) { viewer { id } team(id: $id) { members { nodes { id name } } } }`, + { id: issue.team.id }, + ); + const me = data.viewer.id; + const people = [...data.team.members.nodes].sort((a, b) => (a.id === me ? -1 : b.id === me ? 1 : 0)); + return [ + ...people.map((m) => + action(m.id === me ? `${m.name} (me)` : m.name, () => update(apiKey, issue, { assigneeId: m.id }, m.name), { + icon: Icons.person, + }) + ), + action("Unassign", () => update(apiKey, issue, { assigneeId: null }, "unassigned"), { icon: Icons.cross }), + ]; + }, { icon: Icons.person, shortcut: "a", section: "Issue" }); + + return [ + open(issue.url, "Open in Linear"), + ...(options.details === false ? [] : [action("Show Details", () => issuePage(issue, ctx), { icon: Icons.info })]), + statuses, + priorities, + assignees, + copy(issue.identifier, "Copy Identifier", { shortcut: "period", section: "Copy" }), + copy(issue.url, "Copy URL", { shortcut: "c", section: "Copy" }), + copy(`[${issue.identifier}: ${issue.title}](${issue.url})`, "Copy as Markdown Link", { section: "Copy" }), + ...(issue.branchName ? [copy(issue.branchName, "Copy Branch Name", { icon: Icons.branch, section: "Copy" })] : []), + ]; +} + +/** The full page of an issue: description and properties. */ +export function issuePage(issue: Issue, ctx: Context): DetailSpec { + const head = `# ${issue.title}\n\n`; + const body = issue.description?.trim() || "_No description._"; + return detail({ + title: issue.identifier, + markdown: head + body, + metadata: issueMetadata(issue), + status: `${issue.team.key} · updated ${shortDate(issue.updatedAt)}`, + actions: issueActions(issue, ctx, { details: false }), + }); +} + +export function issueItem(issue: Issue, ctx: Context, options: { section?: string } = {}): Item { + const labels = issue.labels?.nodes ?? []; + const prio = priorityIcon(issue.priority); + return { + id: issue.id, + title: issue.title, + subtitle: issue.identifier, + icon: stateIcon(issue.state), + section: options.section, + keywords: [ + issue.identifier, + issue.state.name, + issue.team.name, + issue.assignee?.name ?? "", + issue.project?.name ?? "", + ...labels.map((l) => l.name), + ].filter(Boolean), + accessories: [ + { icon: prio, tooltip: issue.priorityLabel }, + ...labels.slice(0, 2).map((l) => ({ tag: l.name, color: colorOf(l.color) })), + ...(issue.assignee + ? [{ avatar: issue.assignee.avatarUrl ?? issue.assignee.name, tooltip: issue.assignee.name }] + : []), + { text: shortDate(issue.updatedAt), tooltip: "Updated" }, + ], + detail: { + markdown: excerpt(issue.description), + metadata: issueMetadata(issue), + }, + actions: issueActions(issue, ctx), + }; +} diff --git a/extensions/linear/lib_test.ts b/extensions/linear/lib_test.ts index 07bf0cd..4a618a3 100644 --- a/extensions/linear/lib_test.ts +++ b/extensions/linear/lib_test.ts @@ -1,5 +1,8 @@ import { assertEquals } from "@std/assert"; -import { type Issue, issueItem, sortIssues } from "./lib.ts"; +import type { Context } from "@backslash/api"; +import { colorOf, type Issue, issueItem, issueMetadata, shortDate, sortIssues, stateIcon } from "./lib.ts"; + +const ctx: Context = { args: {}, preferences: { apiKey: "k" }, query: "", signal: new AbortController().signal }; const issue = (identifier: string, type: string, name: string, priority: number, updatedAt = "2026-10-01"): Issue => ({ id: identifier, @@ -12,6 +15,9 @@ const issue = (identifier: string, type: string, name: string, priority: number, state: { id: name, name, type }, team: { id: "t", key: "ENG", name: "Engineering" }, project: identifier === "ENG-1" ? { name: "Launch" } : null, + assignee: { name: "Ada Lovelace", avatarUrl: null }, + labels: { nodes: [{ name: "Bug", color: "#eb5757" }] }, + children: { nodes: [{ state: { type: "completed" } }, { state: { type: "started" } }] }, }); Deno.test("issues: in progress first, then by priority, no priority last", () => { @@ -26,17 +32,31 @@ Deno.test("issues: in progress first, then by priority, no priority last", () => assertEquals(sorted.map((i) => i.identifier), ["ENG-6", "ENG-5", "ENG-4", "ENG-3", "ENG-2", "ENG-1"]); }); -Deno.test("issue item", () => { - const item = issueItem(issue("ENG-1", "started", "In Progress", 1), { section: "In Progress" }); - assertEquals(item.subtitle, "ENG · Launch"); - assertEquals(item.accessories, [{ tag: "Urgent", color: "red" }, { text: "ENG-1" }]); - assertEquals(item.actions?.map((a) => a.title), [ - "Open in Linear", - "Copy Identifier", - "Copy URL", - "Copy as Markdown Link", +Deno.test("states, labels and dates look like Linear", () => { + assertEquals(stateIcon({ name: "In Review", type: "started" }), { + source: "bs-circle-three-quarters", + tint: "green", + }); + assertEquals(stateIcon({ name: "Done", type: "completed" }).tint, "purple"); + assertEquals([colorOf("#eb5757"), colorOf("#4cb782"), colorOf("#5e6ad2"), colorOf("#bec2c8")], [ + "red", + "green", + "blue", + "gray", ]); - const movable = issueItem(issue("ENG-2", "unstarted", "Todo", 0), { moveTo: () => Promise.reject() }); - assertEquals(movable.accessories, [{ text: "ENG-2" }]); - assertEquals(movable.actions?.[1].title, "Change Status…"); + const now = new Date("2026-10-27T12:00:00"); + assertEquals(shortDate("2026-10-24T09:00:00", now), "3d"); +}); + +Deno.test("issue item and its panel", () => { + const item = issueItem(issue("ENG-1", "started", "In Progress", 1), ctx, { section: "In Progress" }); + assertEquals(item.subtitle, "ENG-1"); + assertEquals(item.icon, { source: "bs-circle-half", tint: "yellow" }); + assertEquals(item.accessories?.[0], { icon: { source: "bs-priority-urgent", tint: "orange" }, tooltip: "Urgent" }); + assertEquals(item.accessories?.[1], { tag: "Bug", color: "red" }); + assertEquals(item.accessories?.[2], { avatar: "Ada Lovelace", tooltip: "Ada Lovelace" }); + const titles = item.actions?.map((a) => a.title); + assertEquals(titles?.slice(0, 5), ["Open in Linear", "Show Details", "Change Status", "Set Priority", "Assign To"]); + const labels = issueMetadata(issue("ENG-1", "started", "In Progress", 1)).map((m) => m.label); + assertEquals(labels.slice(0, 6), ["Status", "Priority", "Assignee", "Labels", "Project", "Sub-issues"]); }); diff --git a/extensions/linear/statuses.ts b/extensions/linear/statuses.ts deleted file mode 100644 index 12e2264..0000000 --- a/extensions/linear/statuses.ts +++ /dev/null @@ -1,39 +0,0 @@ -// "Change Status…": the workflow states of the issue's team. - -import { action, type Context, list, pop, toast, type View } from "@backslash/api"; -import { type Issue, linear, notifyChanged, stateRank, type WorkflowState } from "./lib.ts"; - -export async function statusPicker(issue: Issue, ctx: Context): Promise { - const apiKey = ctx.preferences.apiKey as string; - const data = await linear<{ team: { states: { nodes: WorkflowState[] } } }>( - apiKey, - `query($id: String!) { team(id: $id) { states { nodes { id name type position } } } }`, - { id: issue.team.id }, - ); - const states = data.team.states.nodes.sort((a, b) => - stateRank(a.type) - stateRank(b.type) || a.position - b.position - ); - return list({ - title: `${issue.identifier} → status`, - placeholder: "Move to…", - items: states.map((s) => ({ - id: s.id, - title: s.name, - icon: s.id === issue.state.id ? "object-select" : undefined, - accessories: s.id === issue.state.id ? [{ tag: "Current", color: "gray" as const }] : [], - actions: [ - action(`Move to ${s.name}`, async () => { - const res = await linear<{ issueUpdate: { success: boolean } }>( - apiKey, - `mutation($id: String!, $state: String!) { issueUpdate(id: $id, input: { stateId: $state }) { success } }`, - { id: issue.id, state: s.id }, - ); - if (!res.issueUpdate.success) throw new Error("Linear did not update the issue"); - notifyChanged(); - await toast(`${issue.identifier} → ${s.name}`, { style: "success" }); - return pop(); - }), - ], - })), - }); -} diff --git a/extensions/media/backslash.toml b/extensions/media/backslash.toml index d0c0264..b69f537 100644 --- a/extensions/media/backslash.toml +++ b/extensions/media/backslash.toml @@ -2,9 +2,9 @@ id = "media" title = "Media Controls" description = "Control Spotify, browsers, VLC, mpv and any MPRIS player" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "multimedia-player" +icon = "bs-music" license = "MIT" # Players are driven over D-Bus (MPRIS) with busctl, part of systemd. diff --git a/extensions/media/commands/players.ts b/extensions/media/commands/players.ts index e6eb539..3940a63 100644 --- a/extensions/media/commands/players.ts +++ b/extensions/media/commands/players.ts @@ -1,58 +1,130 @@ -import { action, copy, type Item, list, refresh } from "@backslash/api"; +import { + action, + copy, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + updateCommandMetadata, +} from "@backslash/api"; import { clock, type Player } from "../lib.ts"; -import { call, cover, players, raise, setVolume } from "../mpris.ts"; +import { call, cover, players, raise, setLoop, setShuffle, setVolume } from "../mpris.ts"; async function item(p: Player): Promise { const playing = p.status === "Playing"; - const act = (title: string, method: string, options = {}) => + const act = (title: string, run: () => Promise, options = {}) => action(title, async () => { - await call(p, method); + await run(); return refresh(); }, options); - const progress = p.length > 0 ? `${clock(p.position)} / ${clock(p.length)}` : undefined; + const art = await cover(p); + const progress = p.length > 0 ? p.position / p.length : undefined; + const where = p.length > 0 ? `${clock(p.position)} / ${clock(p.length)}` : undefined; + const song = [p.title, p.artist].filter(Boolean).join(" – "); return { id: p.bus, title: p.title, - subtitle: [p.artist, p.album].filter(Boolean).join(" — ") || p.name, - icon: (await cover(p)) ?? (playing ? "▶️" : "⏸️"), + subtitle: p.artist || undefined, + icon: art ?? { source: Icons.music, tint: playing ? "green" : "gray" }, + section: playing ? "Playing" : "Paused", keywords: [p.name, p.artist, p.album], accessories: [ - ...(progress ? [{ text: progress }] : []), - { tag: p.name, color: playing ? "green" : "gray" }, + ...(where ? [{ text: where }] : []), + { icon: { source: playing ? Icons.play : Icons.pause, tint: playing ? "green" : "gray" }, text: p.name }, ], + detail: { + image: art, + metadata: [ + ...(p.artist ? [meta.text("Artist", p.artist, { icon: Icons.person })] : []), + ...(p.album ? [meta.text("Album", p.album, { icon: Icons.music })] : []), + meta.text("Player", `${p.name} · ${playing ? "playing" : p.status.toLowerCase()}`, { + icon: playing ? Icons.play : Icons.pause, + color: playing ? "green" : undefined, + }), + ...(progress !== undefined ? [meta.progress("Position", progress, where)] : []), + ...(p.volume !== undefined ? [meta.progress("Volume", p.volume)] : []), + ...(p.shuffle !== undefined ? [meta.text("Shuffle", p.shuffle ? "On" : "Off", { icon: Icons.shuffle })] : []), + ...(p.loop ? [meta.text("Repeat", p.loop === "None" ? "Off" : p.loop, { icon: Icons.repeat })] : []), + ], + }, actions: [ - act(playing ? "Pause" : "Play", "PlayPause", { icon: playing ? "media-playback-pause" : "media-playback-start" }), - ...(p.canNext ? [act("Next Track", "Next", { icon: "media-skip-forward", shortcut: "n" })] : []), + act(playing ? "Pause" : "Play", () => call(p, "PlayPause"), { icon: playing ? Icons.pause : Icons.play }), + ...(p.canNext ? [act("Next Track", () => call(p, "Next"), { icon: Icons.next, shortcut: "n" })] : []), ...(p.canPrevious - ? [act("Previous Track", "Previous", { icon: "media-skip-backward", shortcut: "p" })] + ? [act("Previous Track", () => call(p, "Previous"), { icon: Icons.previous, shortcut: "p" })] : []), ...(p.volume !== undefined ? [ - action(`Volume Up (${Math.round(p.volume * 100)}%)`, async () => { - await setVolume(p, p.volume! + 0.1); - return refresh(); - }, { icon: "audio-volume-high", shortcut: "Up" }), - action("Volume Down", async () => { - await setVolume(p, p.volume! - 0.1); - return refresh(); - }, { icon: "audio-volume-low", shortcut: "Down" }), + act(`Volume Up (${Math.round(p.volume * 100)} %)`, () => setVolume(p, p.volume! + 0.1), { + icon: Icons.speaker, + shortcut: "Up", + section: "Volume", + }), + act("Volume Down", () => setVolume(p, p.volume! - 0.1), { + icon: Icons.speaker, + shortcut: "Down", + section: "Volume", + }), + submenu( + "Set Volume", + [100, 75, 50, 25, 0].map((v) => + act(v === 0 ? "Mute" : `${v} %`, () => setVolume(p, v / 100), { icon: Icons.speaker }) + ), + { icon: Icons.speaker, section: "Volume" }, + ), ] : []), - ...(p.canRaise ? [action(`Show ${p.name}`, () => raise(p).then(() => undefined), { icon: "window-new" })] : []), - copy([p.title, p.artist].filter(Boolean).join(" – "), "Copy Song Name"), + ...(p.shuffle !== undefined + ? [act(p.shuffle ? "Turn Shuffle Off" : "Turn Shuffle On", () => setShuffle(p, !p.shuffle), { + icon: Icons.shuffle, + shortcut: "s", + section: "Player", + })] + : []), + ...(p.loop + ? [submenu( + "Repeat", + (["None", "Track", "Playlist"] as const).map((l) => + act(l === "None" ? "Off" : l, () => setLoop(p, l), { icon: Icons.repeat }) + ), + { icon: Icons.repeat, section: "Player" }, + )] + : []), + ...(p.canRaise + ? [action(`Show ${p.name}`, () => raise(p).then(() => undefined), { icon: Icons.window, section: "Player" })] + : []), + ...(p.url ? [open(p.url, "Open Track Page", { section: "Player" })] : []), + copy(song, "Copy Song Name", { shortcut: "c", section: "Copy" }), + ...(p.url ? [copy(p.url, "Copy Track Link", { section: "Copy" })] : []), ], }; } export default list({ placeholder: "Filter players…", - empty: { title: "Nothing is playing", description: "Start Spotify, a video in your browser, VLC or mpv" }, + empty: { + title: "Nothing is playing", + description: "Start Spotify, a video in your browser, VLC or mpv", + icon: "bs-music", + }, + status: (items) => items.length > 1 ? `${items.length} players` : items[0]?.subtitle ?? "Media", // Refreshed every two seconds while open, for the position and track changes. async *items({ signal }) { let last = ""; + let lastSong = ""; while (!signal.aborted) { - const items = await Promise.all((await players()).map(item)); - const key = JSON.stringify(items.map((i) => [i.title, i.accessories, i.icon])); + const found = await players(); + const items = await Promise.all(found.map(item)); + const now = found.find((p) => p.status === "Playing"); + const song = now ? [now.artist, now.title].filter(Boolean).join(" — ") : ""; + if (song !== lastSong) { + lastSong = song; + updateCommandMetadata({ subtitle: song || null }).catch(() => {}); + } + const key = JSON.stringify(items.map((i) => [i.title, i.accessories, i.icon, i.detail])); if (key !== last) { last = key; yield items; diff --git a/extensions/media/lib.ts b/extensions/media/lib.ts index 7960964..a765036 100644 --- a/extensions/media/lib.ts +++ b/extensions/media/lib.ts @@ -28,6 +28,10 @@ export interface Player { length: number; position: number; volume?: number; + /** A web page for the track (Spotify gives one). */ + url?: string; + shuffle?: boolean; + loop?: "None" | "Track" | "Playlist"; canNext: boolean; canPrevious: boolean; canRaise: boolean; @@ -47,6 +51,11 @@ export function player(bus: string, props: Record, root: Record { } } } + +export const setShuffle = (p: Player, on: boolean) => + busctl("set-property", p.bus, PATH, IFACE, "Shuffle", "b", String(on)); +export const setLoop = (p: Player, loop: "None" | "Track" | "Playlist") => + busctl("set-property", p.bus, PATH, IFACE, "LoopStatus", "s", loop); diff --git a/extensions/network/backslash.toml b/extensions/network/backslash.toml index cb27791..4396cfb 100644 --- a/extensions/network/backslash.toml +++ b/extensions/network/backslash.toml @@ -2,9 +2,9 @@ id = "network" title = "Network & Devices" description = "Wi-Fi, Bluetooth, audio outputs and VPNs in two keystrokes" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "network-wireless" +icon = "bs-wifi" license = "MIT" # NetworkManager (nmcli), BlueZ (bluetoothctl), PulseAudio/PipeWire (pactl), Tailscale. diff --git a/extensions/network/commands/audio.ts b/extensions/network/commands/audio.ts index fa05505..3324f0c 100644 --- a/extensions/network/commands/audio.ts +++ b/extensions/network/commands/audio.ts @@ -1,4 +1,4 @@ -import { action, exec, type Item, list, refresh } from "@backslash/api"; +import { action, exec, Icons, type Item, list, meta, refresh } from "@backslash/api"; import { type AudioDevice, parseAudio } from "../lib.ts"; const pactl = (...args: string[]) => exec("pactl", args); @@ -15,33 +15,46 @@ async function devices(): Promise { function item(d: AudioDevice): Item { const output = d.kind === "output"; + const icon = output + ? (/headphone|headset|casque/i.test(d.description) ? Icons.headphones : Icons.speaker) + : Icons.mic; return { id: d.name, title: d.description, - subtitle: output ? "Output" : "Input", - icon: output - ? (/headphone|headset|casque/i.test(d.description) ? "audio-headphones" : "audio-speakers") - : "audio-input-microphone", + subtitle: d.isDefault ? (output ? "Playing sound" : "Recording") : undefined, + icon: { source: d.muted ? (output ? Icons.speakerOff : Icons.mic) : icon, tint: d.isDefault ? "green" : "gray" }, section: output ? "Outputs" : "Inputs", accessories: [ ...(d.muted ? [{ tag: "Muted", color: "orange" as const }] : []), - ...(d.isDefault ? [{ tag: "Active", color: "green" as const }] : []), + ...(d.isDefault ? [{ icon: { source: Icons.check, tint: "green" as const }, tooltip: "In use" }] : []), ], + detail: { + metadata: [ + meta.text("Kind", output ? "Output (speakers, headphones)" : "Input (microphone)", { icon }), + meta.text("In use", d.isDefault ? "Yes" : "No", { color: d.isDefault ? "green" : undefined }), + meta.text("Muted", d.muted ? "Yes" : "No", { color: d.muted ? "orange" : undefined }), + meta.text("Device", d.name), + ], + }, actions: [ ...(d.isDefault ? [] : [action(output ? "Use for Sound" : "Use as Microphone", async () => { await pactl(output ? "set-default-sink" : "set-default-source", d.name); return refresh(); - }, { icon: "object-select" })]), + }, { icon: Icons.check })]), action(d.muted ? "Unmute" : "Mute", async () => { await pactl(output ? "set-sink-mute" : "set-source-mute", d.name, "toggle"); return refresh(); - }, { icon: d.muted ? "audio-volume-high" : "audio-volume-muted", shortcut: "m" }), + }, { icon: d.muted ? Icons.speaker : Icons.speakerOff, shortcut: "m" }), ], }; } export default list({ placeholder: "Search outputs and microphones…", - empty: { title: "No audio devices", description: "pactl (PipeWire or PulseAudio) is needed" }, + empty: { title: "No audio devices", description: "pactl (PipeWire or PulseAudio) is needed", icon: "bs-speaker" }, + status: (items) => { + const out = items.find((i) => i.section === "Outputs" && i.subtitle); + return out ? `Sound on ${out.title}` : "Audio"; + }, items: async () => (await devices()).map(item), }); diff --git a/extensions/network/commands/bluetooth.ts b/extensions/network/commands/bluetooth.ts index b4d76ca..a409e8a 100644 --- a/extensions/network/commands/bluetooth.ts +++ b/extensions/network/commands/bluetooth.ts @@ -1,4 +1,4 @@ -import { action, close, type Color, exec, type Item, list, refresh, toast } from "@backslash/api"; +import { action, close, type Color, exec, Icons, type Item, list, meta, refresh, toast } from "@backslash/api"; import { type Device, parseDevices, parseInfo } from "../lib.ts"; const btctl = (...args: string[]) => exec("bluetoothctl", args); @@ -10,29 +10,51 @@ async function devices(): Promise { return all.filter((d) => d.paired).sort((a, b) => Number(b.connected) - Number(a.connected)); } +/** Backslash's icon for bluetoothctl's device class. */ +function deviceIcon(icon: string): string { + if (/headset|headphone/.test(icon)) return Icons.headphones; + if (/audio|speaker/.test(icon)) return Icons.speaker; + if (/keyboard/.test(icon)) return Icons.keyboard; + if (/phone/.test(icon)) return Icons.monitor; + if (/computer/.test(icon)) return Icons.monitor; + return Icons.bluetooth; +} + function item(d: Device): Item { - const battery = d.battery === undefined ? [] : [{ - text: `🔋 ${d.battery}%`, - color: (d.battery < 20 ? "red" : undefined) as Color | undefined, - }]; + const low = d.battery !== undefined && d.battery < 20; return { id: d.mac, title: d.name, - subtitle: d.mac, - icon: d.icon, + subtitle: d.connected ? "Connected" : undefined, + icon: { source: deviceIcon(d.icon), tint: d.connected ? "blue" : "gray" }, section: d.connected ? "Connected" : "Paired", - accessories: [...battery, ...(d.connected ? [{ tag: "Connected", color: "green" as const }] : [])], + accessories: [ + ...(d.battery === undefined + ? [] + : [{ text: `${d.battery} %`, icon: Icons.bolt, color: (low ? "red" : undefined) as Color | undefined }]), + ], + detail: { + metadata: [ + meta.text("Status", d.connected ? "Connected" : "Paired, not connected", { + icon: d.connected ? Icons.check : Icons.bluetooth, + color: d.connected ? "green" : undefined, + }), + ...(d.battery === undefined ? [] : [meta.progress("Battery", d.battery / 100)]), + meta.text("Kind", d.icon.replace(/-/g, " ")), + meta.text("Address", d.mac), + ], + }, actions: d.connected ? [action("Disconnect", async () => { await btctl("disconnect", d.mac); return refresh(); - }, { icon: "bluetooth-disabled" })] + }, { icon: Icons.cross })] : [action("Connect", async () => { await toast(`Connecting to ${d.name}…`); const out = await btctl("--timeout", "15", "connect", d.mac); if (!/successful/i.test(out)) throw new Error(`Could not connect to ${d.name}`); return close(`${d.name} connected`); - }, { icon: "bluetooth-active" })], + }, { icon: Icons.bluetooth })], }; } @@ -40,23 +62,31 @@ const power = (on: boolean) => action(on ? "Turn Bluetooth On" : "Turn Bluetooth Off", async () => { await btctl("power", on ? "on" : "off"); return refresh(); - }, { icon: on ? "bluetooth-active" : "bluetooth-disabled", shortcut: "b" }); + }, { icon: Icons.bluetooth, shortcut: "b" }); export default async () => { const show = await btctl("show").catch(() => undefined); if (show === undefined || !show.includes("Controller")) { - return list({ empty: { title: "No Bluetooth adapter", description: "bluetoothctl found no controller" } }); + return list({ + empty: { title: "No Bluetooth adapter", description: "bluetoothctl found no controller", icon: "bs-bluetooth" }, + }); } if (!/Powered: yes/.test(show)) { return list({ actions: [power(true)], - items: [{ title: "Turn Bluetooth On", icon: "bluetooth-active", actions: [power(true)] }], + status: "Bluetooth is off", + items: [{ title: "Turn Bluetooth On", icon: { source: Icons.bluetooth, tint: "gray" }, actions: [power(true)] }], }); } return list({ placeholder: "Search devices…", actions: [power(false)], - empty: { title: "No paired devices", description: "Pair a device once in your desktop's settings" }, + empty: { + title: "No paired devices", + description: "Pair a device once in your desktop's settings", + icon: "bs-bluetooth", + }, + status: (items) => `${items.filter((i) => i.section === "Connected").length} connected · ${items.length} paired`, items: async () => (await devices()).map(item), }); }; diff --git a/extensions/network/commands/vpn.ts b/extensions/network/commands/vpn.ts index f45c8c7..bda11d6 100644 --- a/extensions/network/commands/vpn.ts +++ b/extensions/network/commands/vpn.ts @@ -1,4 +1,4 @@ -import { action, exec, type Item, list, refresh, toast } from "@backslash/api"; +import { action, exec, Icons, type Item, list, meta, refresh, toast } from "@backslash/api"; import { parseConnections, parseTailscale } from "../lib.ts"; const nmcli = (...args: string[]) => exec("nmcli", args); @@ -11,13 +11,21 @@ async function nmVpns(): Promise { id: `nm:${c.name}`, title: c.name, subtitle: c.type === "wireguard" ? "WireGuard" : "VPN", - icon: on ? "network-vpn" : "network-vpn-disconnected", + icon: { source: Icons.vpn, tint: on ? ("green" as const) : ("gray" as const) }, + section: on ? "Connected" : "Available", accessories: on ? [{ tag: "Connected", color: "green" as const }] : [], + detail: { + metadata: [ + meta.text("Status", on ? "Connected" : "Disconnected", { icon: Icons.vpn, color: on ? "green" : undefined }), + meta.text("Kind", c.type === "wireguard" ? "WireGuard" : "VPN (NetworkManager)"), + ...(on ? [meta.text("Interface", c.device)] : []), + ], + }, actions: [action(on ? "Disconnect" : "Connect", async () => { if (!on) await toast(`Connecting to ${c.name}…`); await nmcli("connection", on ? "down" : "up", "id", c.name); return refresh(); - }, { icon: on ? "network-offline" : "network-vpn" })], + }, { icon: on ? Icons.cross : Icons.vpn })], }; }); } @@ -29,13 +37,25 @@ async function tailscale(): Promise { return [{ id: "tailscale", title: "Tailscale", - subtitle: t.running ? [t.tailnet, t.ip].filter(Boolean).join(" · ") : t.host, - icon: "network-vpn", + subtitle: t.running ? t.tailnet : t.host, + icon: { source: Icons.vpn, tint: t.running ? ("green" as const) : ("gray" as const) }, + section: t.running ? "Connected" : "Available", accessories: t.running ? [{ tag: "Connected", color: "green" as const }] : [], + detail: { + metadata: [ + meta.text("Status", t.running ? "Connected" : "Stopped", { + icon: Icons.vpn, + color: t.running ? "green" : undefined, + }), + ...(t.tailnet ? [meta.text("Tailnet", t.tailnet)] : []), + meta.text("This device", t.host, { icon: Icons.monitor }), + ...(t.ip ? [meta.text("Address", t.ip)] : []), + ], + }, actions: [action(t.running ? "Disconnect" : "Connect", async () => { await exec("tailscale", [t.running ? "down" : "up"]); return refresh(); - }, { icon: "network-vpn" })], + }, { icon: Icons.vpn })], }]; } @@ -44,6 +64,8 @@ export default list({ empty: { title: "No VPN configured", description: "VPN and WireGuard connections of NetworkManager, and Tailscale, show up here", + icon: "bs-vpn", }, + status: (items) => `${items.filter((i) => i.section === "Connected").length} connected`, items: async () => [...await tailscale(), ...await nmVpns()], }); diff --git a/extensions/network/commands/wifi.ts b/extensions/network/commands/wifi.ts index f0d3876..a4f9803 100644 --- a/extensions/network/commands/wifi.ts +++ b/extensions/network/commands/wifi.ts @@ -1,5 +1,18 @@ -import { action, close, exec, field, form, type Item, list, refresh, toast } from "@backslash/api"; -import { bars, parseConnections, parseWifi, type Wifi } from "../lib.ts"; +import { + action, + close, + type Color, + exec, + field, + form, + Icons, + type Item, + list, + meta, + refresh, + toast, +} from "@backslash/api"; +import { parseConnections, parseWifi, type Wifi } from "../lib.ts"; const nmcli = (...args: string[]) => exec("nmcli", args); @@ -18,32 +31,55 @@ const passwordForm = (w: Wifi) => submit: (values) => connect(w, false, String(values.password)), }); +const strength = (signal: number): [string, Color] => + signal >= 70 + ? ["Excellent", "green"] + : signal >= 50 + ? ["Good", "green"] + : signal >= 30 + ? ["Fair", "yellow"] + : ["Weak", "orange"]; + function item(w: Wifi, saved: boolean): Item { const open = w.security === ""; + const [quality, color] = strength(w.signal); return { id: w.ssid, title: w.ssid, - subtitle: open ? "Open network" : w.security, - icon: w.active ? "network-wireless-connected" : open ? "network-wireless" : "network-wireless-encrypted", - section: w.active ? "Connected" : "Networks", + subtitle: w.active ? "Connected" : saved ? "Saved" : undefined, + icon: { source: Icons.wifi, tint: w.active ? "accent" as Color : color }, + section: w.active ? "Connected" : saved ? "Saved networks" : "Other networks", accessories: [ - ...(saved && !w.active ? [{ tag: "Saved" }] : []), - { text: `${bars(w.signal)} ${w.signal}%` }, + ...(open ? [{ tag: "Open", color: "orange" as const }] : [{ icon: Icons.lock, tooltip: w.security }]), + { text: `${w.signal} %`, tooltip: quality }, ], + detail: { + metadata: [ + meta.progress("Signal", w.signal / 100, `${w.signal} % · ${quality}`), + meta.text("Security", open ? "None (open network)" : w.security, { + icon: open ? Icons.alert : Icons.lock, + color: open ? "orange" : undefined, + }), + meta.text("Status", w.active ? "Connected" : saved ? "Saved: joins without a password" : "Not joined yet", { + icon: w.active ? Icons.check : Icons.wifi, + color: w.active ? "green" : undefined, + }), + ], + }, actions: w.active ? [ action("Disconnect", async () => { await nmcli("connection", "down", "id", w.ssid); return refresh(); - }, { icon: "network-offline", destructive: true }), + }, { icon: Icons.wifiOff, destructive: true }), ] : [ - action("Connect", () => (open || saved ? connect(w, saved) : passwordForm(w)), { icon: "network-wireless" }), + action("Connect", () => (open || saved ? connect(w, saved) : passwordForm(w)), { icon: Icons.wifi }), ...(saved ? [action("Forget Network", async () => { await nmcli("connection", "delete", "id", w.ssid); return refresh(); - }, { destructive: true, confirm: `Forget ${w.ssid} and its password?` })] + }, { icon: Icons.trash, destructive: true, confirm: `Forget ${w.ssid} and its password?` })] : []), ], }; @@ -54,24 +90,31 @@ const toggle = (on: boolean) => await nmcli("radio", "wifi", on ? "on" : "off"); if (on) await new Promise((r) => setTimeout(r, 2500)); // let it scan return refresh(); - }, { icon: on ? "network-wireless" : "network-wireless-offline", shortcut: "w" }); + }, { icon: on ? Icons.wifi : Icons.wifiOff, shortcut: "w" }); export default async () => { const enabled = (await nmcli("radio", "wifi").catch(() => "missing")).trim(); if (enabled === "missing") { - return list({ empty: { title: "NetworkManager is not available", description: "nmcli was not found" } }); + return list({ + empty: { title: "NetworkManager is not available", description: "nmcli was not found", icon: "bs-wifi-off" }, + }); } if (enabled !== "enabled") { return list({ - empty: { title: "Wi-Fi is off" }, + empty: { title: "Wi-Fi is off", icon: "bs-wifi-off" }, actions: [toggle(true)], - items: [{ title: "Turn Wi-Fi On", icon: "network-wireless", actions: [toggle(true)] }], + status: "Wi-Fi is off", + items: [{ title: "Turn Wi-Fi On", icon: { source: Icons.wifi, tint: "gray" }, actions: [toggle(true)] }], }); } return list({ placeholder: "Search networks…", actions: [toggle(false)], - empty: { title: "No networks found" }, + empty: { title: "No networks found", icon: "bs-wifi" }, + status: (items) => { + const on = items.find((i) => i.section === "Connected"); + return on ? `Connected to ${on.title} · ${items.length} networks` : `${items.length} networks`; + }, items: async () => { const [scan, conns] = await Promise.all([ nmcli("-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "auto"), diff --git a/extensions/packages/backslash.toml b/extensions/packages/backslash.toml index 948eaaa..74cb9f8 100644 --- a/extensions/packages/backslash.toml +++ b/extensions/packages/backslash.toml @@ -2,9 +2,9 @@ id = "packages" title = "Packages" description = "Search your distribution's packages and Flathub" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "system-software-install" +icon = "bs-package" license = "MIT" [permissions] diff --git a/extensions/packages/commands/search.ts b/extensions/packages/commands/search.ts index a23bc34..6dcb90f 100644 --- a/extensions/packages/commands/search.ts +++ b/extensions/packages/commands/search.ts @@ -1,4 +1,4 @@ -import { copy, exec, type Item, list, open } from "@backslash/api"; +import { copy, exec, Icons, type Item, list, meta, open, paste } from "@backslash/api"; interface Package { name: string; @@ -77,30 +77,51 @@ async function flathub(query: string, signal: AbortSignal): Promise { }); } -function item(p: Package): Item { +const SOURCES: Record = { + apt: "Debian / Ubuntu (apt)", + dnf: "Fedora (dnf)", + pacman: "Arch (pacman)", + zypper: "openSUSE (zypper)", + flatpak: "Flathub", +}; + +function item(p: Package, query: string): Item { + const exact = p.name === query || p.name.endsWith(`.${query}`); + const install = INSTALL[p.source](p.name); return { id: `${p.source}:${p.name}`, title: p.name, subtitle: p.summary, - icon: p.source === "flatpak" ? "application-x-flatpak" : "package-x-generic", + icon: { source: p.source === "flatpak" ? Icons.apps : Icons.package, tint: exact ? "blue" : "gray" }, section: p.source === "flatpak" ? "Flathub" : "Distribution", - accessories: [{ tag: p.source }, ...(p.version ? [{ text: p.version }] : [])], + accessories: [...(p.version ? [{ text: p.version }] : []), { tag: p.source }], + detail: { + markdown: `\`${install}\``, + metadata: [ + meta.text("Package", p.name, { icon: Icons.package }), + ...(p.version ? [meta.text("Version", p.version)] : []), + meta.text("From", SOURCES[p.source]), + meta.link("Page", PAGE[p.source](p.name).replace(/^https:\/\//, ""), PAGE[p.source](p.name)), + ], + }, actions: [ - copy(INSTALL[p.source](p.name), "Copy Install Command"), + copy(install, "Copy Install Command"), + paste(install, "Paste Install Command", { icon: Icons.terminal }), open(PAGE[p.source](p.name), "Open Package Page"), - copy(p.name, "Copy Name"), + copy(p.name, "Copy Name", { section: "Copy" }), ], }; } export default list({ placeholder: "Search packages…", - empty: { title: "Search packages", description: "apt, dnf, pacman or zypper, and Flathub" }, + empty: { title: "Search packages", description: "apt, dnf, pacman or zypper, and Flathub", icon: "bs-package" }, + status: (items) => items.length ? `${items.length} package${items.length === 1 ? "" : "s"}` : "Packages", async search(query, { signal }) { if (query.trim().length < 2) return []; const [sys, flat] = await Promise.all([system(query, signal), flathub(query, signal)]); // Exact and prefix matches first. const rank = (p: Package) => (p.name === query ? 0 : p.name.startsWith(query) ? 1 : 2); - return [...sys.sort((a, b) => rank(a) - rank(b)).slice(0, 40), ...flat.slice(0, 20)].map(item); + return [...sys.sort((a, b) => rank(a) - rank(b)).slice(0, 40), ...flat.slice(0, 20)].map((p) => item(p, query)); }, }); diff --git a/extensions/ports/backslash.toml b/extensions/ports/backslash.toml index 358745b..5f7c626 100644 --- a/extensions/ports/backslash.toml +++ b/extensions/ports/backslash.toml @@ -2,9 +2,9 @@ id = "ports" title = "Ports" description = "What listens on :3000? Dev servers and services by port, with their process and project" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "network-server" +icon = "bs-plug" license = "MIT" # ss (iproute2) lists sockets, ps and readlink describe their processes. diff --git a/extensions/ports/commands/ports.ts b/extensions/ports/commands/ports.ts index 9f574de..d66ba05 100644 --- a/extensions/ports/commands/ports.ts +++ b/extensions/ports/commands/ports.ts @@ -1,4 +1,4 @@ -import { action, copy, exec, type Item, list, open, refresh, toast } from "@backslash/api"; +import { action, copy, exec, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; import { byPort, isLocal, KNOWN, type Listener, parseSs } from "../lib.ts"; const HOME = Deno.env.get("HOME") ?? ""; @@ -41,44 +41,74 @@ function item(port: number, sockets: Listener[], proc?: Process): Item { await new Promise((r) => setTimeout(r, 300)); return refresh(); }, options); + const addresses = [...new Set(sockets.map((s) => s.address))]; return { id: String(port), title: `:${port}`, - subtitle: proc - ? [proc.name, project && `in ${tilde(proc.cwd!)}`].filter(Boolean).join(" ") - : KNOWN[port] ?? "System service", - icon: proc ? "application-x-executable" : "network-server", + subtitle: proc ? proc.name : KNOWN[port] ?? "System service", + icon: { source: proc ? Icons.plug : Icons.server, tint: proc ? (local ? "green" : "orange") : "gray" }, section: proc ? "Yours" : "System", keywords: [proc?.command ?? "", proc?.cwd ?? "", KNOWN[port] ?? "", String(proc?.pid ?? "")], accessories: [ - ...(project ? [{ text: project }] : []), + ...(project ? [{ icon: Icons.folder, text: project }] : []), local ? { tag: "localhost", color: "gray" as const } : { tag: "network", color: "orange" as const }, ...(proc ? [{ text: `pid ${proc.pid}` }] : []), ], + detail: { + markdown: proc ? `\`${proc.command.length > 300 ? proc.command.slice(0, 300) + "…" : proc.command}\`` : undefined, + metadata: [ + meta.text("Port", port, { icon: Icons.plug }), + meta.text( + "Reachable", + local ? "From this computer only" : "From the network", + { icon: local ? Icons.lock : Icons.globe, color: local ? undefined : "orange" }, + ), + meta.tags("Listening on", addresses), + ...(proc + ? [ + meta.separator(), + meta.text("Program", proc.name, { icon: Icons.terminal }), + meta.text("PID", proc.pid), + ...(proc.cwd ? [meta.text("Folder", tilde(proc.cwd), { icon: Icons.folder })] : []), + ] + : [meta.text("Service", KNOWN[port] ?? "Unknown")]), + meta.link("Open", url.replace("http://", ""), url), + ], + }, actions: [ open(url, "Open in Browser"), ...(proc ? [ kill("TERM", "Stop Process", { - icon: "process-stop", + icon: Icons.stop, destructive: true, confirm: `Stop ${proc.name} (pid ${proc.pid})?`, shortcut: "k", + section: "Process", }), - kill("KILL", "Force Kill", { destructive: true, confirm: `Kill ${proc.name} right away?` }), - ...(proc.cwd ? [open(proc.cwd, "Open Folder", { icon: "folder-open" })] : []), - copy(proc.command, "Copy Command Line"), - copy(String(proc.pid), "Copy PID"), + kill("KILL", "Force Kill", { + icon: Icons.cross, + destructive: true, + confirm: `Kill ${proc.name} right away?`, + section: "Process", + }), + ...(proc.cwd ? [open(proc.cwd, "Open Folder", { icon: Icons.folder, section: "Process" })] : []), + copy(proc.command, "Copy Command Line", { section: "Copy" }), + copy(String(proc.pid), "Copy PID", { section: "Copy" }), ] : []), - copy(url, "Copy URL"), + copy(url, "Copy URL", { shortcut: "c", section: "Copy" }), ], }; } export default list({ placeholder: "Filter ports, programs, projects…", - empty: { title: "Nothing is listening", description: "Start a dev server and it shows up here" }, + empty: { title: "Nothing is listening", description: "Start a dev server and it shows up here", icon: "bs-plug" }, + status: (items) => { + const yours = items.filter((i) => i.section === "Yours").length; + return `${items.length} port${items.length === 1 ? "" : "s"} · ${yours} yours`; + }, items: async () => { const sockets = parseSs(await exec("ss", ["-Hltnp"])); const procs = await describe(sockets.flatMap((s) => s.pids)); diff --git a/extensions/projects/backslash.toml b/extensions/projects/backslash.toml index 1c84add..4a159b2 100644 --- a/extensions/projects/backslash.toml +++ b/extensions/projects/backslash.toml @@ -2,9 +2,9 @@ id = "projects" title = "Code Projects" description = "Recent projects of VS Code, VSCodium, Cursor, Zed and JetBrains IDEs, plus your git folders" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "applications-development" +icon = "bs-code" license = "MIT" [permissions] diff --git a/extensions/projects/commands/projects.ts b/extensions/projects/commands/projects.ts index 90fbaf0..accc2a6 100644 --- a/extensions/projects/commands/projects.ts +++ b/extensions/projects/commands/projects.ts @@ -1,4 +1,4 @@ -import { action, close, copy, exec, type Item, list, open } from "@backslash/api"; +import { action, close, type Color, copy, exec, Icons, type Item, list, meta, open, submenu } from "@backslash/api"; import { gitStatus, type Recent, remotePage } from "../lib.ts"; import { HOME, projects } from "../sources.ts"; @@ -72,51 +72,113 @@ async function git(dir: string): Promise { } } +/** What a project is written in, from the files at its root. */ +const MARKERS: [string, string, Color][] = [ + ["Cargo.toml", "Rust", "orange"], + ["go.mod", "Go", "teal"], + ["deno.json", "Deno", "gray"], + ["tsconfig.json", "TypeScript", "blue"], + ["package.json", "JavaScript", "yellow"], + ["pyproject.toml", "Python", "blue"], + ["requirements.txt", "Python", "blue"], + ["pom.xml", "Java", "orange"], + ["build.gradle", "Java", "orange"], + ["build.gradle.kts", "Kotlin", "purple"], + ["Gemfile", "Ruby", "red"], + ["composer.json", "PHP", "purple"], + ["mix.exs", "Elixir", "purple"], + ["pubspec.yaml", "Dart", "teal"], + ["CMakeLists.txt", "C++", "pink"], + ["meson.build", "C", "gray"], + ["flake.nix", "Nix", "blue"], +]; + +function language(dir: string): [string, Color] | undefined { + for (const [file, name, color] of MARKERS) { + try { + Deno.statSync(`${dir}/${file}`); + return [name, color]; + } catch { /* next */ } + } + return undefined; +} + +const date = (ms: number) => new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short", year: "numeric" }); + function item(r: Recent, prefs: Prefs, g?: Git): Item { const name = r.path.split("/").pop()!.replace(/\.code-workspace$/, ""); + const lang = language(r.path); const accessories = []; if (g) { - accessories.push({ text: g.branch }); + accessories.push({ icon: Icons.branch, text: g.branch }); if (g.changes) accessories.push({ tag: `${g.changes} changed`, color: "orange" as const }); - if (g.ahead) accessories.push({ tag: `↑${g.ahead}`, color: "blue" as const }); + if (g.ahead) accessories.push({ tag: `↑${g.ahead}`, color: "blue" as const, tooltip: "Commits not pushed" }); } - accessories.push({ tag: r.editor }); + accessories.push({ text: r.editor }); + const openIn = (title: string, command: string) => + action(title, () => { + launch(command, [r.path]); + return close(); + }, { icon: Icons.code }); + const others = Object.entries(EDITORS).filter(([cmd]) => + cmd !== r.command && cmd !== "zeditor" && cmd !== "code-oss" + ); return { id: r.path, title: name, subtitle: tilde(r.path), - icon: "folder", - keywords: [r.path, r.editor, g?.branch ?? ""], + icon: { source: Icons.folder, tint: lang?.[1] ?? "gray" }, + keywords: [r.path, r.editor, g?.branch ?? "", lang?.[0] ?? ""], accessories, + detail: { + metadata: [ + meta.text("Folder", tilde(r.path), { icon: Icons.folder }), + ...(lang ? [meta.text("Language", lang[0], { icon: Icons.dot, color: lang[1] })] : []), + meta.text("Editor", r.editor, { icon: Icons.code }), + ...(r.opened ? [meta.text("Opened", date(r.opened), { icon: Icons.clock })] : []), + ...(g + ? [ + meta.separator(), + meta.text("Branch", g.branch, { icon: Icons.branch }), + meta.text("Changes", g.changes ? `${g.changes} file${g.changes > 1 ? "s" : ""} changed` : "Clean", { + icon: g.changes ? Icons.dot : Icons.check, + color: g.changes ? "orange" : "green", + }), + ...(g.ahead + ? [meta.text("Not pushed", `${g.ahead} commit${g.ahead > 1 ? "s" : ""}`, { color: "blue" })] + : []), + ...(g.page ? [meta.link("Remote", g.page.replace(/^https:\/\//, ""), g.page)] : []), + ] + : []), + ], + }, actions: [ - action(`Open in ${r.editor}`, () => { - launch(r.command, [r.path]); - return close(); - }, { icon: "document-open" }), + openIn(`Open in ${r.editor}`, r.command), action("Open in Terminal", () => { openTerminal(r.path, prefs.terminal || undefined); return close(); - }, { icon: "utilities-terminal", shortcut: "t" }), - open(r.path, "Open Folder", { icon: "folder-open" }), + }, { icon: Icons.terminal, shortcut: "t" }), + open(r.path, "Open Folder", { icon: Icons.folder }), ...(g?.page ? [open(g.page, "Open Repository Page", { shortcut: "o" })] : []), - ...(r.command !== prefs.editor - ? [action(`Open in ${editorName(prefs.editor)}`, () => { - launch(prefs.editor, [r.path]); - return close(); - })] - : []), - copy(r.path, "Copy Path"), + submenu("Open With", [ + ...(r.command !== prefs.editor ? [openIn(editorName(prefs.editor), prefs.editor)] : []), + ...others.map(([cmd, title]) => openIn(title, cmd)), + ], { icon: Icons.apps, section: "Open" }), + copy(r.path, "Copy Path", { shortcut: "c", section: "Copy" }), + ...(g?.branch ? [copy(g.branch, "Copy Branch Name", { icon: Icons.branch, section: "Copy" })] : []), ], }; } export default list({ - placeholder: "Search projects…", + placeholder: "Search projects by name, path, branch, language…", empty: { title: "No projects found", description: "Open folders in VS Code, Cursor, Zed or a JetBrains IDE, or keep git repositories in ~/Projects or ~/code", + icon: "bs-folder", }, + status: (items) => `${items.length} project${items.length === 1 ? "" : "s"}`, async *items({ preferences }) { const prefs = preferences as unknown as Prefs; const found = projects(prefs.editor || "code"); diff --git a/extensions/systemd/backslash.toml b/extensions/systemd/backslash.toml index 1a088d5..7fe2a4f 100644 --- a/extensions/systemd/backslash.toml +++ b/extensions/systemd/backslash.toml @@ -2,9 +2,9 @@ id = "systemd" title = "Services" description = "Start, stop and inspect systemd services" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "preferences-system" +icon = "bs-server" license = "MIT" [permissions] diff --git a/extensions/systemd/commands/services.ts b/extensions/systemd/commands/services.ts index 6a0f9f8..ebdedce 100644 --- a/extensions/systemd/commands/services.ts +++ b/extensions/systemd/commands/services.ts @@ -1,4 +1,4 @@ -import { action, type Color, copy, detail, exec, type Item, list, refresh, toast } from "@backslash/api"; +import { action, type Color, copy, detail, exec, Icons, type Item, list, meta, refresh, toast } from "@backslash/api"; interface Unit { name: string; @@ -28,51 +28,118 @@ function control(u: Unit, verb: string) { }; } +/** `systemctl show` properties, for the logs page. */ +async function show(u: Unit): Promise> { + const out = await exec("systemctl", [ + ...(u.user ? ["--user"] : []), + "show", + u.name, + "-p", + "MainPID,MemoryCurrent,ActiveEnterTimestamp,FragmentPath,NRestarts", + ]).catch(() => ""); + return Object.fromEntries(out.split("\n").map((l) => l.split("=", 2)).filter((kv) => kv.length === 2)); +} + +const bytes = (n: number) => + n >= 1 << 30 + ? `${(n / (1 << 30)).toFixed(1)} GB` + : n >= 1 << 20 + ? `${(n / (1 << 20)).toFixed(0)} MB` + : `${Math.round(n / 1024)} kB`; + function item(u: Unit): Item { const running = u.active === "active"; + const failed = u.active === "failed"; return { id: `${u.user ? "user" : "system"}:${u.name}`, title: u.name.replace(/\.service$/, ""), subtitle: u.description, - icon: "system-run", - section: u.user ? "User services" : "System services", + icon: { source: failed ? Icons.alert : Icons.server, tint: color(u) }, + section: failed ? "Failed" : u.user ? "User services" : "System services", keywords: [u.sub, u.active], accessories: [{ tag: u.sub, color: color(u) }], + detail: { + metadata: [ + meta.text("State", `${u.active} (${u.sub})`, { icon: failed ? Icons.alert : Icons.server, color: color(u) }), + meta.text("Scope", u.user ? "Your user" : "System (asks for your password to change)", { + icon: u.user ? Icons.person : Icons.lock, + }), + meta.text("Unit", u.name), + ], + }, actions: [ action("Show Logs", async () => { - const logs = await exec("journalctl", [ - ...(u.user ? ["--user"] : []), - "-u", - u.name, - "-n", - "100", - "--no-pager", - "-o", - "short-iso", - ]).catch((e) => `Could not read the logs: ${e.message}`); + const [logs, props] = await Promise.all([ + exec("journalctl", [ + ...(u.user ? ["--user"] : []), + "-u", + u.name, + "-n", + "150", + "--no-pager", + "-o", + "short-iso", + ]).catch((e) => `Could not read the logs: ${e.message}`), + show(u), + ]); + const memory = Number(props.MemoryCurrent); return detail({ title: u.name, - markdown: `# ${u.name}\n\n${u.description}\n\n\`\`\`\n${logs || "No logs."}\n\`\`\``, - metadata: { State: `${u.active} (${u.sub})`, Scope: u.user ? "user" : "system" }, + markdown: `\`\`\`\n${logs || "No logs."}\n\`\`\``, + metadata: [ + meta.text("State", `${u.active} (${u.sub})`, { color: color(u) }), + ...(props.ActiveEnterTimestamp + ? [meta.text("Since", props.ActiveEnterTimestamp, { icon: Icons.clock })] + : []), + ...(props.MainPID && props.MainPID !== "0" ? [meta.text("PID", props.MainPID)] : []), + ...(memory > 0 && memory < 2 ** 60 ? [meta.text("Memory", bytes(memory), { icon: Icons.cpu })] : []), + ...(props.NRestarts && props.NRestarts !== "0" + ? [meta.text("Restarts", props.NRestarts, { color: "orange" })] + : []), + ...(props.FragmentPath ? [meta.text("Unit file", props.FragmentPath, { icon: Icons.file })] : []), + ], + status: `${u.user ? "User" : "System"} service · last 150 lines`, + actions: [copy(logs, "Copy Logs")], }); - }), - action("Restart", control(u, "restart"), { icon: "view-refresh", shortcut: "r" }), + }, { icon: Icons.text }), + action("Restart", control(u, "restart"), { icon: Icons.refresh, shortcut: "r", section: "Service" }), running - ? action("Stop", control(u, "stop"), { destructive: true, confirm: `Stop ${u.name}?`, shortcut: "s" }) - : action("Start", control(u, "start"), { shortcut: "s" }), - copy(u.name, "Copy Unit Name"), + ? action("Stop", control(u, "stop"), { + icon: Icons.stop, + destructive: true, + confirm: `Stop ${u.name}?`, + shortcut: "s", + section: "Service", + }) + : action("Start", control(u, "start"), { icon: Icons.play, shortcut: "s", section: "Service" }), + copy(u.name, "Copy Unit Name", { section: "Copy" }), ], }; } export default list({ placeholder: "Filter services…", - empty: { title: "No services", description: "systemd does not seem to run here" }, - items: async () => { + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "failed", title: "Failed" }, + { value: "all", title: "All" }, + ], + }, + empty: { title: "No services here", description: "Nothing matches this filter", icon: "bs-server" }, + status: (items) => `${items.length} service${items.length === 1 ? "" : "s"}`, + items: async ({ filter }) => { const [user, system] = await Promise.all([units(true), units(false)]); - // Running and failed services first. - const order = (u: Unit) => (u.active === "failed" ? 0 : u.active === "active" ? 1 : 2); - return [...user, ...system].sort((a, b) => Number(a.user) - Number(b.user) || order(a) - order(b)).map(item) - .sort((a, b) => (a.section === b.section ? 0 : a.section === "User services" ? -1 : 1)); + const keep = (u: Unit) => + filter === "failed" + ? u.active === "failed" + : filter === "all" + ? true + : u.active === "active" || u.active === "failed"; + // Failed first, then your services, then the system's. + const order = (u: Unit) => (u.active === "failed" ? 0 : u.user ? 1 : 2); + return [...user, ...system].filter(keep).sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name)).map( + item, + ); }, }); diff --git a/extensions/timers/backslash.toml b/extensions/timers/backslash.toml index 7e75c78..0ecde22 100644 --- a/extensions/timers/backslash.toml +++ b/extensions/timers/backslash.toml @@ -2,9 +2,9 @@ id = "timers" title = "Timers" description = "Timers and Pomodoro that ring even when Backslash is closed (systemd timers)" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "alarm" +icon = "bs-timer" license = "MIT" # Timers are transient systemd user units: they survive the launcher and diff --git a/extensions/timers/commands/timers.ts b/extensions/timers/commands/timers.ts index dac9dbc..aa6aa65 100644 --- a/extensions/timers/commands/timers.ts +++ b/extensions/timers/commands/timers.ts @@ -1,21 +1,34 @@ -import { action, close, field, form, type Item, list, refresh, toast } from "@backslash/api"; +import { + action, + close, + type Color, + field, + form, + Icons, + type Item, + list, + meta, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; import { countdown, humanize, parseDuration } from "../lib.ts"; import { running, start, stop, type Timer } from "../timers.ts"; -const PRESETS: [string, number, string][] = [ - ["Pomodoro", 25 * 60, "🍅"], - ["Short Break", 5 * 60, "☕"], - ["Long Break", 15 * 60, "🛋️"], - ["Tea", 4 * 60, "🫖"], - ["Pasta", 10 * 60, "🍝"], - ["One Hour", 60 * 60, "⏳"], +const PRESETS: [string, number, Color, string][] = [ + ["Pomodoro", 25 * 60, "red", "Focus for 25 minutes, then take a short break"], + ["Short Break", 5 * 60, "teal", "Five minutes away from the screen"], + ["Long Break", 15 * 60, "blue", "After four pomodoros"], + ["Tea", 4 * 60, "green", "Black tea: 3 to 5 minutes"], + ["Pasta", 10 * 60, "yellow", "Most dried pasta: 9 to 11 minutes"], + ["One Hour", 60 * 60, "gray", "A full hour"], ]; const startAction = (title: string, seconds: number, name: string) => action(title, async () => { await start(seconds, name); return close(`${name}: ${humanize(seconds)}`); - }, { icon: "media-playback-start" }); + }, { icon: Icons.play }); const custom = form({ title: "New Timer", @@ -36,55 +49,87 @@ const custom = form({ }, }); -const newTimer = action("New Timer…", () => custom, { icon: "list-add", shortcut: "n" }); +const newTimer = action("New Timer…", () => custom, { icon: Icons.plus, shortcut: "n" }); + +const clockTime = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); function runningItem(t: Timer): Item { - const left = (t.ends - Date.now()) / 1000; - const at = new Date(t.ends).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + const left = Math.max(0, (t.ends - Date.now()) / 1000); + const started = t.ends - t.seconds * 1000; + const soon = left < 60; return { id: t.unit, title: t.name, - subtitle: `${humanize(t.seconds)} · rings at ${at}`, - icon: "⏱️", + subtitle: `rings at ${clockTime(t.ends)}`, + icon: { source: Icons.timer, tint: soon ? "orange" : "blue" }, section: "Running", - accessories: [{ tag: countdown(left), color: left < 60 ? "orange" : "blue" }], + accessories: [{ tag: countdown(left), color: soon ? "orange" : "blue" }], + detail: { + stats: [ + { label: "left", value: countdown(left), color: soon ? "orange" : "blue" }, + { label: "rings at", value: clockTime(t.ends) }, + ], + metadata: [ + meta.progress("Elapsed", 1 - left / t.seconds), + meta.text("Duration", humanize(t.seconds), { icon: Icons.timer }), + meta.text("Started", clockTime(started), { icon: Icons.clock }), + meta.text("Rings", "With a notification and a sound, even if Backslash is closed", { icon: Icons.bell }), + ], + }, actions: [ action("Stop Timer", async () => { await stop(t); return refresh(); - }, { icon: "media-playback-stop", destructive: true }), + }, { icon: Icons.stop, destructive: true, shortcut: "k" }), action("Restart", async () => { await stop(t); await start(t.seconds, t.name); return refresh(); - }, { icon: "view-refresh" }), + }, { icon: Icons.refresh, shortcut: "r" }), ], }; } -const presets: Item[] = PRESETS.map(([name, seconds, icon]) => ({ - id: `preset:${name}`, - title: name, - subtitle: humanize(seconds), - icon, - section: "Start", - actions: [startAction(`Start ${humanize(seconds)}`, seconds, name)], -})); +const presets = (): Item[] => + PRESETS.map(([name, seconds, color, about]) => ({ + id: `preset:${name}`, + title: name, + subtitle: humanize(seconds), + icon: { source: name.includes("Break") || name === "One Hour" ? Icons.clock : Icons.timer, tint: color }, + section: "Start", + detail: { + stats: [{ label: "duration", value: humanize(seconds) }], + markdown: about, + metadata: [meta.text("Rings at", clockTime(Date.now() + seconds * 1000), { icon: Icons.bell })], + }, + actions: [startAction(`Start ${humanize(seconds)}`, seconds, name)], + })); export default list({ placeholder: "Timers…", + empty: { title: "No timer", icon: "bs-timer" }, + status: (items) => { + const n = items.filter((i) => i.section === "Running").length; + return n ? `${n} running` : "Type a duration to start a timer"; + }, actions: [newTimer], // Live countdown: refreshed every second while open. async *items({ signal }) { + let lastCount: string | null | undefined; while (!signal.aborted) { const timers = await running(); - yield [...timers.map(runningItem), ...presets, { + const count = timers.length ? `${timers.length} running` : null; + if (count !== lastCount) { + lastCount = count; + updateCommandMetadata({ subtitle: count }).catch(() => {}); + } + yield [...timers.map(runningItem), ...presets(), { id: "custom", title: "Custom Timer…", subtitle: "Any duration: 25m, 1h30, 90s", - icon: "list-add", + icon: { source: Icons.plus, tint: "gray" }, section: "Start", - actions: [action("New Timer…", () => custom, { icon: "list-add" })], + actions: [action("New Timer…", () => custom, { icon: Icons.plus })], }]; await new Promise((r) => setTimeout(r, 1000)); } diff --git a/extensions/todoist/backslash.toml b/extensions/todoist/backslash.toml index 081ca90..2b3eea8 100644 --- a/extensions/todoist/backslash.toml +++ b/extensions/todoist/backslash.toml @@ -2,9 +2,9 @@ id = "todoist" title = "Todoist" description = "Today's tasks, search and quick add in natural language" -version = "1.0.0" +version = "1.1.0" author = "Backslash" -icon = "✅" +icon = "bs-circle-check" license = "MIT" [permissions] diff --git a/extensions/todoist/commands/tasks.ts b/extensions/todoist/commands/tasks.ts index 0525f43..184cbb9 100644 --- a/extensions/todoist/commands/tasks.ts +++ b/extensions/todoist/commands/tasks.ts @@ -3,7 +3,8 @@ import { all, type Project, sortTasks, type Task, taskItem } from "../lib.ts"; export default list({ placeholder: "Search your tasks…", - empty: { title: "No tasks", icon: "🎉" }, + empty: { title: "No tasks", icon: "bs-circle-check" }, + status: (items) => `${items.length} task${items.length === 1 ? "" : "s"}`, async items({ preferences, signal }) { const token = preferences.token as string; const [tasks, projects] = await Promise.all([ @@ -11,9 +12,12 @@ export default list({ all(token, "/projects", signal), ]); const order = new Map(projects.map((p, i) => [p.id, i])); - const names = new Map(projects.map((p) => [p.id, p.name])); + const byId = new Map(projects.map((p) => [p.id, p])); return sortTasks(tasks) .sort((a, b) => (order.get(a.project_id) ?? 1e9) - (order.get(b.project_id) ?? 1e9)) - .map((t) => taskItem(t, token, { section: names.get(t.project_id) ?? "Other" })); + .map((t) => { + const project = byId.get(t.project_id); + return taskItem(t, token, { section: project?.name ?? "Other", project }); + }); }, }); diff --git a/extensions/todoist/commands/today.ts b/extensions/todoist/commands/today.ts index ba1ae3b..bc2d3b2 100644 --- a/extensions/todoist/commands/today.ts +++ b/extensions/todoist/commands/today.ts @@ -1,18 +1,57 @@ -import { list } from "@backslash/api"; -import { all, dueSection, type Project, sortTasks, type Task, taskItem } from "../lib.ts"; +import { list, updateCommandMetadata } from "@backslash/api"; +import { all, dueSection, localDate, type Project, sortTasks, type Task, taskItem, todoist } from "../lib.ts"; + +const QUERIES: Record = { + today: "today | overdue", + week: "7 days | overdue", + nodate: "no date", +}; + +/** Tasks completed today, for the footer (0 when the API refuses). */ +async function doneToday(token: string, signal: AbortSignal): Promise { + try { + const since = `${localDate(new Date())}T00:00:00Z`; + const until = new Date(Date.now() + 86_400_000).toISOString().slice(0, 19) + "Z"; + const page = await todoist<{ items: unknown[] }>( + token, + `/tasks/completed/by_completion_date?since=${since}&until=${until}&limit=200`, + { signal }, + ); + return page.items?.length ?? 0; + } catch { + return 0; + } +} + +let done = 0; export default list({ - placeholder: "Filter today's tasks…", - empty: { title: "Nothing left for today", icon: "🎉" }, - async items({ preferences, signal }) { + placeholder: "Filter tasks by name, label, project…", + filter: { + options: [ + { value: "today", title: "Today" }, + { value: "week", title: "Next 7 days" }, + { value: "nodate", title: "No date" }, + ], + }, + empty: { title: "Nothing left", description: "Enjoy the rest of the day.", icon: "bs-circle-check" }, + status: (items) => `${items.length} to do${done ? ` · ${done} done today` : ""}`, + async items({ preferences, signal, filter }) { const token = preferences.token as string; - const [tasks, projects] = await Promise.all([ - all(token, `/tasks/filter?query=${encodeURIComponent("today | overdue")}`, signal), + const key = filter ?? "today"; + const [tasks, projects, completed] = await Promise.all([ + all(token, `/tasks/filter?query=${encodeURIComponent(QUERIES[key] ?? QUERIES.today)}`, signal), all(token, "/projects", signal), + key === "today" ? doneToday(token, signal) : Promise.resolve(0), ]); - const names = new Map(projects.map((p) => [p.id, p.name])); - return sortTasks(tasks).map((t) => - taskItem(t, token, { section: dueSection(t), project: names.get(t.project_id) }) + done = completed; + const byId = new Map(projects.map((p) => [p.id, p])); + const sorted = sortTasks(tasks); + if (key === "today") { + updateCommandMetadata({ subtitle: sorted.length ? `${sorted.length} today` : null }).catch(() => {}); + } + return sorted.map((t) => + taskItem(t, token, { section: key === "nodate" ? undefined : dueSection(t), project: byId.get(t.project_id) }) ); }, }); diff --git a/extensions/todoist/lib.ts b/extensions/todoist/lib.ts index af56a4f..46c9431 100644 --- a/extensions/todoist/lib.ts +++ b/extensions/todoist/lib.ts @@ -1,6 +1,19 @@ // Todoist API v1 helper and the mapping from tasks to launcher items. -import { action, copy, type Item, open, refresh, toast } from "@backslash/api"; +import { + type Action, + action, + type Color, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + submenu, + toast, +} from "@backslash/api"; export const API = "https://api.todoist.com/api/v1"; @@ -47,15 +60,46 @@ export interface Task { project_id: string; labels: string[]; due: { date: string; string: string; is_recurring: boolean; datetime?: string | null } | null; + duration?: { amount: number; unit: string } | null; + added_at?: string | null; + note_count?: number; } export interface Project { id: string; name: string; + color?: string; + is_inbox_project?: boolean; } export const taskUrl = (id: string) => `https://app.todoist.com/app/task/${id}`; +/** Todoist's project colors, as the launcher's palette. */ +const PROJECT_COLORS: Record = { + berry_red: "pink", + red: "red", + orange: "orange", + yellow: "yellow", + olive_green: "green", + lime_green: "green", + green: "green", + mint_green: "teal", + teal: "teal", + sky_blue: "blue", + light_blue: "blue", + blue: "blue", + grape: "purple", + violet: "purple", + lavender: "purple", + magenta: "pink", + salmon: "orange", + charcoal: "gray", + grey: "gray", + taupe: "gray", +}; + +export const projectColor = (p: Project | undefined): Color => PROJECT_COLORS[p?.color ?? ""] ?? "gray"; + /** `YYYY-MM-DD` in local time. */ export function localDate(d: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); @@ -72,7 +116,10 @@ export function dueSection(task: Task, now = new Date()): string { const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); if (date === localDate(tomorrow)) return "Tomorrow"; - return date; + const d = new Date(`${date}T12:00:00`); + const days = Math.round((d.getTime() - now.getTime()) / 86_400_000); + if (days < 7) return d.toLocaleDateString("en", { weekday: "long" }); + return d.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" }); } /** Time of day for tasks due at a precise time. */ @@ -84,12 +131,15 @@ export function dueTime(task: Task): string | undefined { } /** The API's priority 4 is the app's P1. */ -const PRIORITY: Record = { - 4: { tag: "P1", color: "red" }, - 3: { tag: "P2", color: "orange" }, - 2: { tag: "P3", color: "blue" }, +const PRIORITY: Record = { + 4: { name: "P1", color: "red" }, + 3: { name: "P2", color: "orange" }, + 2: { name: "P3", color: "blue" }, + 1: { name: "P4", color: "gray" }, }; +export const priorityOf = (n: number) => PRIORITY[n] ?? PRIORITY[1]; + export function sortTasks(tasks: Task[]): Task[] { const due = (t: Task) => t.due?.datetime ?? t.due?.date ?? "9999"; return [...tasks].sort((a, b) => @@ -97,36 +147,112 @@ export function sortTasks(tasks: Task[]): Task[] { ); } +function overdue(task: Task, now = new Date()): boolean { + return !!task.due && task.due.date.slice(0, 10) < localDate(now); +} + +export function taskMetadata(task: Task, project: Project | undefined, now = new Date()): Metadata[] { + const p = priorityOf(task.priority); + const lines: Metadata[] = []; + if (task.due) { + const when = [dueSection(task, now), dueTime(task)].filter(Boolean).join(" at "); + lines.push(meta.text("Due", when, { icon: Icons.calendar, color: overdue(task, now) ? "red" : undefined })); + if (task.due.is_recurring) lines.push(meta.text("Repeats", task.due.string, { icon: Icons.refresh })); + } else { + lines.push(meta.text("Due", "No date", { icon: Icons.calendar })); + } + lines.push(meta.text("Priority", p.name, { icon: Icons.flag, color: p.color === "gray" ? undefined : p.color })); + if (project) lines.push(meta.text("Project", project.name, { icon: Icons.dot, color: projectColor(project) })); + if (task.labels.length) lines.push(meta.tags("Labels", task.labels)); + if (task.duration) lines.push(meta.text("Duration", `${task.duration.amount} ${task.duration.unit}`)); + if (task.note_count) lines.push(meta.text("Comments", task.note_count, { icon: Icons.comment })); + return lines; +} + +async function edit(token: string, task: Task, body: Record, message: string) { + await todoist(token, `/tasks/${task.id}`, { method: "POST", body }); + await toast(message, { style: "success" }); + return refresh(); +} + +export function taskActions(task: Task, token: string): Action[] { + return [ + action("Complete Task", async () => { + await todoist(token, `/tasks/${task.id}/close`, { method: "POST" }); + await toast(task.due?.is_recurring ? "Done, next one scheduled" : "Completed", { style: "success" }); + return refresh(); + }, { icon: Icons.check }), + open(taskUrl(task.id), "Open in Todoist"), + submenu( + "Reschedule", + [ + ["Today", "today"], + ["Tomorrow", "tomorrow"], + ["This Weekend", "saturday"], + ["Next Week", "next monday"], + ["No Date", "no date"], + ].map(([title, due]) => + action(title, () => edit(token, task, { due_string: due }, `Moved to ${title.toLowerCase()}`), { + icon: Icons.calendar, + }) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ), + submenu( + "Set Priority", + [4, 3, 2, 1].map((n) => + action(priorityOf(n).name, () => edit(token, task, { priority: n }, `Priority ${priorityOf(n).name}`), { + icon: Icons.flag, + }) + ), + { icon: Icons.flag, shortcut: "p", section: "Task" }, + ), + copy(task.content, "Copy Task", { shortcut: "c", section: "Copy" }), + copy(taskUrl(task.id), "Copy Link", { section: "Copy" }), + action("Delete Task", async () => { + await todoist(token, `/tasks/${task.id}`, { method: "DELETE" }); + await toast("Deleted", { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: "Delete this task?", + section: "Danger", + shortcut: "Delete", + }), + ]; +} + export function taskItem( task: Task, token: string, - options: { section?: string; project?: string; now?: Date } = {}, + options: { section?: string; project?: Project; now?: Date } = {}, ): Item { - const priority = PRIORITY[task.priority]; + const p = priorityOf(task.priority); const time = dueTime(task); - const section = options.section ?? dueSection(task, options.now); + const now = options.now ?? new Date(); + const when = time ?? (task.due && !options.section ? dueSection(task, now) : undefined); return { id: task.id, title: task.content, - subtitle: [options.project, task.description.split("\n")[0]].filter(Boolean).join(" · "), - icon: "⭕", + subtitle: task.description.split("\n")[0] || undefined, + icon: { source: Icons.todo, tint: p.color }, section: options.section, - keywords: [...task.labels, options.project ?? "", task.due?.string ?? ""].filter(Boolean), + keywords: [...task.labels, options.project?.name ?? "", task.due?.string ?? ""].filter(Boolean), accessories: [ - ...(priority ? [priority] : []), - ...task.labels.map((l) => ({ text: `@${l}` })), - ...(time ? [{ text: time }] : task.due && !options.section ? [{ text: section }] : []), - ...(task.due?.is_recurring ? [{ icon: "view-refresh" }] : []), - ], - actions: [ - action("Complete Task", async () => { - await todoist(token, `/tasks/${task.id}/close`, { method: "POST" }); - await toast(task.due?.is_recurring ? "Done, next occurrence scheduled" : "Completed", { style: "success" }); - return refresh(); - }, { icon: "object-select" }), - open(taskUrl(task.id), "Open in Todoist"), - copy(task.content, "Copy Task", { shortcut: "c" }), - copy(taskUrl(task.id), "Copy Link"), + ...task.labels.slice(0, 2).map((l) => ({ text: `@${l}` })), + ...(task.due?.is_recurring ? [{ icon: Icons.refresh, tooltip: task.due.string }] : []), + ...(when + ? [{ text: when, color: overdue(task, now) ? "red" as const : time ? "green" as const : undefined }] + : []), + ...(options.project + ? [{ icon: { source: Icons.dot, tint: projectColor(options.project) }, text: options.project.name }] + : []), ], + detail: { + markdown: task.description || undefined, + metadata: taskMetadata(task, options.project, now), + }, + actions: taskActions(task, token), }; } diff --git a/extensions/todoist/lib_test.ts b/extensions/todoist/lib_test.ts index 9ed0f3d..3ff8647 100644 --- a/extensions/todoist/lib_test.ts +++ b/extensions/todoist/lib_test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "@std/assert"; -import { dueSection, sortTasks, type Task, taskItem } from "./lib.ts"; +import { dueSection, projectColor, sortTasks, type Task, taskItem, taskMetadata } from "./lib.ts"; const task = (id: string, date: string | null, priority = 1, extra: Partial = {}): Task => ({ id, @@ -12,12 +12,14 @@ const task = (id: string, date: string | null, priority = 1, extra: Partial { assertEquals( - ["2026-10-20", "2026-10-27", "2026-10-28", "2026-11-02", null].map((d) => dueSection(task("x", d), NOW)), - ["Overdue", "Today", "Tomorrow", "2026-11-02", "No date"], + ["2026-10-20", "2026-10-27", "2026-10-28", "2026-10-30", "2026-11-12", null].map((d) => + dueSection(task("x", d), NOW) + ), + ["Overdue", "Today", "Tomorrow", "Friday", "Thu, Nov 12", "No date"], ); assertEquals(dueSection(task("x", "2026-10-27T18:00:00"), NOW), "Today"); }); @@ -33,12 +35,20 @@ Deno.test("sorted by day, then priority (4 = P1), then time", () => { assertEquals(sorted.map((t) => t.id), ["c", "b", "a", "e", "d"]); }); -Deno.test("task item", () => { - const item = taskItem(task("1", "2026-10-30", 4, { labels: ["work"], description: "Details\nmore" }), "tok", { - project: "Inbox", - now: NOW, - }); - assertEquals(item.subtitle, "Inbox · Details"); - assertEquals(item.accessories, [{ tag: "P1", color: "red" }, { text: "@work" }, { text: "2026-10-30" }]); - assertEquals(item.actions?.map((a) => a.title), ["Complete Task", "Open in Todoist", "Copy Task", "Copy Link"]); +Deno.test("task item and panel", () => { + const project = { id: "p", name: "Work", color: "blue" }; + const t = task("1", "2026-10-26", 4, { labels: ["work"], description: "Details\nmore" }); + const item = taskItem(t, "tok", { project, now: NOW }); + assertEquals(item.icon, { source: "bs-circle", tint: "red" }); + assertEquals(item.subtitle, "Details"); + assertEquals(item.accessories?.map((a) => a.text), ["@work", "Overdue", "Work"]); + assertEquals(item.accessories?.[1].color, "red"); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Complete Task", + "Open in Todoist", + "Reschedule", + "Set Priority", + ]); + assertEquals(taskMetadata(t, project, NOW).map((m) => m.label), ["Due", "Priority", "Project", "Labels"]); + assertEquals(projectColor(project), "blue"); }); diff --git a/registry/ai.toml b/registry/ai.toml index 978ab7b..0b5398a 100644 --- a/registry/ai.toml +++ b/registry/ai.toml @@ -1,13 +1,14 @@ +# registry/ai.toml id = "ai" title = "AI" description = "Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama" author = "Backslash" -icon = "✨" -version = "1.1.0" +icon = "bs-sparkle" +version = "1.2.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/ai" -commit = "4355eb15974003b4d0f61febb95dd5dca5c4d614" -checksum = "sha256:6f09352f8175b132111367f1926cd9e36bf86567d7cfd44fda862a749703f8bc" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:a3400b0015f17c90fb0eaa755cf8ff9d8deec040d33eba8047f83d846ec13560" permissions = ["Access api.anthropic.com", "Access api.openai.com", "Access api.mistral.ai", "Access api.groq.com", "Access openrouter.ai", "Access localhost:11434", "Read the clipboard"] commands = ["Ask AI", "Fix Spelling and Grammar", "Improve Writing", "Summarize", "Translate", "Explain"] diff --git a/registry/browser.toml b/registry/browser.toml index 1a45347..77ec03c 100644 --- a/registry/browser.toml +++ b/registry/browser.toml @@ -1,13 +1,14 @@ +# registry/browser.toml id = "browser" title = "Browser" description = "History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi" author = "Backslash" -icon = "web-browser" -version = "1.0.0" +icon = "bs-globe" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/browser" -commit = "e2762ad04c6bc8eddadcd95bc2640611cdd763fc" -checksum = "sha256:d55daf0f700dcc194b092741cf66ddac4f413022c26f73aec28f6c5d52974396" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:e8f4a7d46edafd1c6418df983b6ac628e6dbf60c7da77a0f25ddc298f840a27f" permissions = ["Read ~/.mozilla/firefox", "Read ~/snap/firefox/common/.mozilla/firefox", "Read ~/.var/app/org.mozilla.firefox/.mozilla/firefox", "Read ~/.librewolf", "Read ~/.config/google-chrome", "Read ~/.config/chromium", "Read ~/.config/BraveSoftware/Brave-Browser", "Read ~/.config/microsoft-edge", "Read ~/.config/vivaldi", "Read ~/.var/app/com.google.Chrome/config/google-chrome", "Read ~/.var/app/org.chromium.Chromium/config/chromium", "Read ~/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser", "Read /tmp/backslash-browser", "Write /tmp/backslash-browser", "Read the environment variable HOME"] commands = ["Search Browser History", "Search Firefox Tabs"] diff --git a/registry/docker.toml b/registry/docker.toml index 0e392f3..a2e10c0 100644 --- a/registry/docker.toml +++ b/registry/docker.toml @@ -1,13 +1,14 @@ +# registry/docker.toml id = "docker" title = "Containers" description = "Docker and Podman containers: start, stop, logs" author = "Backslash" -icon = "utilities-terminal" -version = "1.0.0" +icon = "bs-box" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/docker" -commit = "a64f60074557298bf4cabf235c6e91e1f4e54082" -checksum = "sha256:7b338ac7fb63f6b846d10366f2c680dd62069ab442d46816b02430fd8ff0addc" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:7d93b9666dc4f0b495e27d68acd14b5379b61ef30617b84cbb6c18fa862ebcf0" permissions = ["Run docker", "Run podman"] commands = ["Containers"] diff --git a/registry/emoji.toml b/registry/emoji.toml index a8b0d91..3c78e7c 100644 --- a/registry/emoji.toml +++ b/registry/emoji.toml @@ -1,13 +1,14 @@ +# registry/emoji.toml id = "emoji" title = "Emoji & Symbols" description = "Search emoji and symbols by name and copy them" author = "Backslash" icon = "😀" -version = "1.2.0" +version = "1.3.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/emoji" -commit = "4355eb15974003b4d0f61febb95dd5dca5c4d614" -checksum = "sha256:3d6c8065bb45587d5e68b3226ab7ab0ed73cc0772f7daf494f53b6b0575b543f" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:b8122aa64c7e0f9d8b7c4244a7ecd00eb246977925ea5ebfb14adbfb9a93b542" permissions = [] commands = ["Search Emoji & Symbols"] diff --git a/registry/github.toml b/registry/github.toml index dbe31ca..feab594 100644 --- a/registry/github.toml +++ b/registry/github.toml @@ -1,13 +1,14 @@ +# registry/github.toml id = "github" title = "GitHub" -description = "Search repositories and follow your pull requests" +description = "Pull requests with their checks and reviews, your issues, and repository search" author = "Backslash" -icon = "folder-remote" -version = "1.0.0" +icon = "bs-repo" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/github" -commit = "a64f60074557298bf4cabf235c6e91e1f4e54082" -checksum = "sha256:37f89a1ec235d4369ac134883ce1a6e9288ffa7e5f3e903ecaca127ee881b346" -permissions = ["Access api.github.com"] -commands = ["Search GitHub Repositories", "My Pull Requests"] +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:c6a3a5e86ab451078b776ee4bd69c2353745de3d60cc1fc8e9b464f874f5da38" +permissions = ["Access api.github.com", "Access avatars.githubusercontent.com", "Run gh"] +commands = ["Search GitHub Repositories", "My Pull Requests", "My GitHub Issues"] diff --git a/registry/linear.toml b/registry/linear.toml index a7c1dab..c5f9767 100644 --- a/registry/linear.toml +++ b/registry/linear.toml @@ -1,14 +1,14 @@ # registry/linear.toml id = "linear" title = "Linear" -description = "Your issues, search and quick issue creation for Linear" +description = "Your issues with their status, priority, labels and assignee; search, triage and quick issue creation" author = "Backslash" -icon = "📐" -version = "1.0.0" +icon = "bs-circle-half" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/linear" -commit = "97cc142208fb8d4e194971568d5ba739e99038e0" -checksum = "sha256:d30e2cd5d02683ebd2bae8dde4a6dd9a6ffa67bee156200c9cfce523ec7e8c45" -permissions = ["Access api.linear.app"] +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:4b1265e61dd21f9b283b8b93e4b1481f25d7ed552f3c2c82b9a8368be974ba42" +permissions = ["Access api.linear.app", "Access public.linear.app"] commands = ["My Linear Issues", "Search Linear Issues", "Create Linear Issue"] diff --git a/registry/media.toml b/registry/media.toml index e4a4bc6..8197262 100644 --- a/registry/media.toml +++ b/registry/media.toml @@ -1,13 +1,14 @@ +# registry/media.toml id = "media" title = "Media Controls" description = "Control Spotify, browsers, VLC, mpv and any MPRIS player" author = "Backslash" -icon = "multimedia-player" -version = "1.0.0" +icon = "bs-music" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/media" -commit = "e2762ad04c6bc8eddadcd95bc2640611cdd763fc" -checksum = "sha256:deefc94c41d8c0927383d084a785f54321d7df5348be0073541dfdb764dcfa1f" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:2d061583b7ea53d0cfca3e540a631cb3cb9f8985ec77d00f29d76fd85adaac54" permissions = ["Access i.scdn.co", "Run busctl", "Read /tmp/backslash-media", "Write /tmp/backslash-media"] commands = ["Now Playing", "Play / Pause", "Next Track", "Previous Track"] diff --git a/registry/network.toml b/registry/network.toml index 331cb56..c21c64c 100644 --- a/registry/network.toml +++ b/registry/network.toml @@ -1,13 +1,14 @@ +# registry/network.toml id = "network" title = "Network & Devices" description = "Wi-Fi, Bluetooth, audio outputs and VPNs in two keystrokes" author = "Backslash" -icon = "network-wireless" -version = "1.0.0" +icon = "bs-wifi" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/network" -commit = "e2762ad04c6bc8eddadcd95bc2640611cdd763fc" -checksum = "sha256:1852ff1ea8397cc9c3bdf8a68cecbb79039102bd19d84be02053b43024a67a72" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:8a253af22ce5a7897d663aadb9e23d0de219dff16ce2b50d0b3c46791919e8fe" permissions = ["Run nmcli", "Run bluetoothctl", "Run pactl", "Run tailscale"] commands = ["Wi-Fi", "Bluetooth Devices", "Audio Output", "VPN"] diff --git a/registry/packages.toml b/registry/packages.toml index 1955245..40edd54 100644 --- a/registry/packages.toml +++ b/registry/packages.toml @@ -1,13 +1,14 @@ +# registry/packages.toml id = "packages" title = "Packages" description = "Search your distribution's packages and Flathub" author = "Backslash" -icon = "system-software-install" -version = "1.0.0" +icon = "bs-package" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/packages" -commit = "a64f60074557298bf4cabf235c6e91e1f4e54082" -checksum = "sha256:b16dfa71c1501ab138039585387b2d1e8d05d65129b18845e17316606990571d" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:6a40605ea44b58a1ad25bed1fe842435184725b3d7c3b75043988a7dc8745914" permissions = ["Run apt-cache", "Run dnf", "Run pacman", "Run zypper", "Run flatpak"] commands = ["Search Packages"] diff --git a/registry/ports.toml b/registry/ports.toml index aabe1c8..d9d32d3 100644 --- a/registry/ports.toml +++ b/registry/ports.toml @@ -1,13 +1,14 @@ +# registry/ports.toml id = "ports" title = "Ports" description = "What listens on :3000? Dev servers and services by port, with their process and project" author = "Backslash" -icon = "network-server" -version = "1.0.0" +icon = "bs-plug" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/ports" -commit = "e2762ad04c6bc8eddadcd95bc2640611cdd763fc" -checksum = "sha256:89743571c50453b6a803c13ee14b40dadff04a3e2adaf0ed147d0cb17486b196" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:1ae873601bac3850d55d1d59a3778dd150c9d3229d983a8a2342f01c36fec256" permissions = ["Run ss", "Run ps", "Run readlink", "Run kill", "Read the environment variable HOME"] commands = ["Listening Ports"] diff --git a/registry/projects.toml b/registry/projects.toml index 3301c3b..445caa0 100644 --- a/registry/projects.toml +++ b/registry/projects.toml @@ -1,13 +1,14 @@ +# registry/projects.toml id = "projects" title = "Code Projects" description = "Recent projects of VS Code, VSCodium, Cursor, Zed and JetBrains IDEs, plus your git folders" author = "Backslash" -icon = "applications-development" -version = "1.0.0" +icon = "bs-code" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/projects" -commit = "2c1d52fecbad855c1b7f802d09dcdf75920f92b1" -checksum = "sha256:93986363f7e123275c098a9549b428b953c7c9ab249672a63afc6fa5e0e2873f" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:0f1caedea3098f8e6c54b74b1235150346f6c6fbe32fcd6b66b9dbfd1b47a9fb" permissions = ["Run git", "Run code", "Run code-oss", "Run codium", "Run cursor", "Run windsurf", "Run zed", "Run zeditor", "Run idea", "Run pycharm", "Run webstorm", "Run goland", "Run clion", "Run rustrover", "Run phpstorm", "Run rider", "Run rubymine", "Run datagrip", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read ~/.config/Code", "Read ~/.config/Code - OSS", "Read ~/.config/VSCodium", "Read ~/.config/Cursor", "Read ~/.config/Windsurf", "Read ~/.var/app/com.visualstudio.code/config/Code", "Read ~/.var/app/com.vscodium.codium/config/VSCodium", "Read ~/.config/JetBrains", "Read ~/.local/share/zed", "Read ~/Projects", "Read ~/projects", "Read ~/Code", "Read ~/code", "Read ~/dev", "Read ~/Developer", "Read ~/src", "Read ~/git", "Read ~/repos", "Read ~/workspace", "Read /tmp/backslash-projects", "Write /tmp/backslash-projects", "Read the environment variable HOME"] commands = ["Search Projects"] diff --git a/registry/systemd.toml b/registry/systemd.toml index f4f2010..374ff8c 100644 --- a/registry/systemd.toml +++ b/registry/systemd.toml @@ -1,13 +1,14 @@ +# registry/systemd.toml id = "systemd" title = "Services" description = "Start, stop and inspect systemd services" author = "Backslash" -icon = "preferences-system" -version = "1.0.0" +icon = "bs-server" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/systemd" -commit = "a64f60074557298bf4cabf235c6e91e1f4e54082" -checksum = "sha256:9234b202cf96d9a49fe707f2c2048420cbb6c380a67acb40c7377805a5b06b83" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:e74b08775317f85ab6e840075a3ac53da7f4f8d3f6775d6854bf81b07dd73bfa" permissions = ["Run systemctl", "Run journalctl"] commands = ["Services"] diff --git a/registry/timers.toml b/registry/timers.toml index fb0bf4d..0f5ffb1 100644 --- a/registry/timers.toml +++ b/registry/timers.toml @@ -1,13 +1,14 @@ +# registry/timers.toml id = "timers" title = "Timers" description = "Timers and Pomodoro that ring even when Backslash is closed (systemd timers)" author = "Backslash" -icon = "alarm" -version = "1.0.0" +icon = "bs-timer" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/timers" -commit = "e2762ad04c6bc8eddadcd95bc2640611cdd763fc" -checksum = "sha256:422031852c14716d9f0d7babb2e29e79db9bb4ee82162c8d8c27edb21eaa8908" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:f701bc42e379b145b28c400093d9e9036363d519e1dd9c1b710c08b415858ed1" permissions = ["Run systemd-run", "Run systemctl"] commands = ["Timers", "Start Timer"] diff --git a/registry/todoist.toml b/registry/todoist.toml index 7e325a5..e096e7c 100644 --- a/registry/todoist.toml +++ b/registry/todoist.toml @@ -3,12 +3,12 @@ id = "todoist" title = "Todoist" description = "Today's tasks, search and quick add in natural language" author = "Backslash" -icon = "✅" -version = "1.0.0" +icon = "bs-circle-check" +version = "1.1.0" repository = "https://github.com/backslash-app/plugins" path = "extensions/todoist" -commit = "97cc142208fb8d4e194971568d5ba739e99038e0" -checksum = "sha256:9e648dc34ea5adb317bc05033378c5a258fa9f35ba3132ff87a6d78ce6f418ae" +commit = "4aaa0dadfad431b389d39e2d13062528764afeb0" +checksum = "sha256:ef675d92fdd7aaaedf21e4fe7de0258b59ae2fddba499d61271b177d972d4bdf" permissions = ["Access api.todoist.com"] commands = ["Todoist Today", "Search Todoist Tasks", "Add Todoist Task"]