Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ dist
out
.DS_Store
*.log*

# IDE project files
.idea/
*.iml
44 changes: 44 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,47 @@ A Backslash plugin is a simple module that you can add to extend Backslash's fun
1. **Because you can**. You're a Linux user, you already solve your problems with shell scripts. Why not make them pretty with Backslash?
2. **Automation**. If you're doing the same repetitive tasks, turn them into a plugin, and let Backslash handle it.
3. **Sharing is caring**. Once you've built something cool, share it with the rest of the Linux community. (They might even use it!)

## Advanced Tips

### Showing Toast Notifications

Need to give users a heads-up? Plugins receive a `toast` helper that lets you trigger Sonner notifications in the app:

```js
module.exports = {
commands: {
'demo-command': {
run: async (_, { toast }) => {
try {
await doSomething()
toast.success('Done!', { description: 'Everything worked.' })
} catch (error) {
toast.error('Uh oh', { description: error.message })
}
}
}
}
}
```

Available helpers: `toast.show({ title, description, type, duration })`, `toast.success`, `toast.info`, `toast.warning`, and `toast.error`.

### Clearing the Search Input

Want the search box to reset after a successful command? Use the `search` helper that Backslash passes into every plugin:

```js
module.exports = {
commands: {
'demo-command': {
run: async (_, { search }) => {
await doSomething()
search.clear()
}
}
}
}
```

If you're targeting older Backslash versions, no worries—the helper is optional. Calling `search.clear()` simply does nothing when the renderer doesn't support it yet.
1,818 changes: 1,818 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"js-yaml": "^4.1.0",
"lodash.shuffle": "^4.2.0",
"lucide-react": "^0.462.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
Expand Down
13 changes: 13 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ declare global {
getDisabledPlugins: () => Promise<string[]>
getHotkeys: () => Promise<{ [key: string]: string }>
setHotkey: (type: string, hotkey: string) => Promise<void>
onPluginToast: (callback: (payload: PluginToastPayload) => void) => () => void
onPluginSearch: (callback: (event: PluginSearchEvent) => void) => () => void
showMainWindow: () => Promise<void>
hideMainWindow: () => Promise<void>
reloadApp: () => Promise<void>
Expand Down Expand Up @@ -86,11 +88,22 @@ declare global {
data: Record<string, string>
}

type PluginToastPayload = {
type: 'success' | 'info' | 'warning' | 'error'
title: string
description?: string
duration?: number
}

type ActionT = {
action: (param?: string) => void
name: string
}

type PluginSearchEvent = {
action: 'clear'
}

type ManifestT = {
author: string
commands: {
Expand Down
9 changes: 7 additions & 2 deletions src/main/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import cheerio from 'cheerio'
import yaml from 'js-yaml'
import ini from 'ini'
import { readdir, readFile } from 'fs/promises'
import { toast } from './pluginToast'
import { search } from './pluginSearch'

storage.setDataPath(os.tmpdir())

Expand Down Expand Up @@ -39,7 +41,9 @@ const DEPS = {
clipboard,
exec,
shell,
path
path,
toast,
search
}

/**
Expand Down Expand Up @@ -88,6 +92,7 @@ interface Application {
command: string
isImmediate: boolean
}

const parseDesktopFile = (content: string, filePath: string): Application | null => {
try {
const parsed = ini.parse(content)
Expand Down Expand Up @@ -276,7 +281,7 @@ export const runPluginAction = async (
* canceled.
*/
export const choosePluginsDir = async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] })
const result = await dialog.showOpenDialog({properties: ['openDirectory']})

if (!result.canceled && result.filePaths.length > 0) {
const newPath = result.filePaths[0]
Expand Down
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
setHotkey
} from './handlers'
import { setupAutoUpdater } from './autoUpdater'
import { registerPluginEmitters } from './pluginEvents'

let mainWindow: BrowserWindow
const gotTheLock = app.requestSingleInstanceLock()
Expand Down Expand Up @@ -120,6 +121,7 @@ if (!gotTheLock) {
})

await createWindow()
registerPluginEmitters(() => mainWindow ?? null)
await registerGlobalShortcut()
createTray()

Expand Down
36 changes: 36 additions & 0 deletions src/main/pluginEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { BrowserWindow } from 'electron'

import { registerToastEmitter } from './pluginToast'
import { registerSearchEmitter } from './pluginSearch'

type PluginEmitterRegistration<Payload> = {
channel: string
register: (emit: (payload: Payload) => void) => void
}

const registry: PluginEmitterRegistration<unknown>[] = [
{ channel: 'plugin-toast', register: registerToastEmitter },
{ channel: 'plugin-search', register: registerSearchEmitter }
]

/**
* Installs all plugin-to-renderer emitters using a shared window lookup.
*
* Each registration function receives an emitter that proxies payloads to the renderer
* through the appropriate IPC channel once the BrowserWindow is ready.
*
* @param getWindow - Lazy getter returning the current BrowserWindow or null when unavailable.
*/
export const registerPluginEmitters = (getWindow: () => BrowserWindow | null) => {
registry.forEach(({ channel, register }) => {
register((payload) => {
const window = getWindow()
if (!window) {
console.warn(`Renderer window is not ready for channel "${channel}". Dropping event.`)
return
}

window.webContents.send(channel, payload)
})
})
}
31 changes: 31 additions & 0 deletions src/main/pluginSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type SearchEvent = {
action: 'clear'
}

type SearchEmitter = (event: SearchEvent) => void

let searchEmitter: SearchEmitter | null = null

/**
* Registers the renderer callback responsible for handling search-related events.
* @param emitter - Function invoked with search event payloads.
*/
export const registerSearchEmitter = (emitter: SearchEmitter) => {
searchEmitter = emitter
}

const emitSearchEvent = (event: SearchEvent) => {
if (!searchEmitter) {
console.warn('Search emitter not registered. Ignoring search event request.')
return
}

searchEmitter(event)
}

/**
* Helpers exposed to plugins for interacting with the search input.
*/
export const search = {
clear: () => emitSearchEvent({ action: 'clear' })
}
68 changes: 68 additions & 0 deletions src/main/pluginToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
type ToastType = 'success' | 'info' | 'warning' | 'error'

type ToastPayload = {
type?: ToastType
title: string
description?: string
duration?: number
}

export type PluginToastEvent = {
type: ToastType
title: string
description?: string
duration?: number
}

type ToastOptions = {
description?: string
duration?: number
}

type ToastEmitter = (payload: PluginToastEvent) => void

let toastEmitter: ToastEmitter | null = null

/**
* Registers the renderer callback responsible for showing toast notifications.
* @param emitter - Function invoked with toast payloads.
*/
export const registerToastEmitter = (emitter: ToastEmitter) => {
toastEmitter = emitter
}

const emitToast = (payload: ToastPayload) => {
if (!toastEmitter) {
console.warn('Toast emitter not registered. Ignoring toast request.')
return
}

if (!payload.title) {
console.warn('Toast payload requires a title. Ignoring toast request.')
return
}

toastEmitter({
type: payload.type ?? 'info',
title: payload.title,
description: payload.description,
duration: payload.duration
})
}

const createToastMethod =
(type: ToastType) =>
(title: string, options: ToastOptions = {}) => {
emitToast({ type, title, ...options })
}

/**
* Helpers exposed to plugins for triggering toast notifications.
*/
export const toast = {
show: emitToast,
info: createToastMethod('info'),
success: createToastMethod('success'),
warning: createToastMethod('warning'),
error: createToastMethod('error')
}
16 changes: 16 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ if (process.contextIsolated) {
getDisabledPlugins: () => {
return ipcRenderer.invoke('get-disabled-plugins')
},
onPluginToast: (callback) => {
const channel = 'plugin-toast'
const subscription = (_event, payload) => callback(payload)
ipcRenderer.on(channel, subscription)
return () => {
ipcRenderer.removeListener(channel, subscription)
}
},
onPluginSearch: (callback) => {
const channel = 'plugin-search'
const subscription = (_event, payload) => callback(payload)
ipcRenderer.on(channel, subscription)
return () => {
ipcRenderer.removeListener(channel, subscription)
}
},
showMainWindow: () => {
return ipcRenderer.send('show-main-window')
},
Expand Down
13 changes: 11 additions & 2 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useRef, KeyboardEvent } from 'react'
import { useState, useRef, KeyboardEvent, useCallback } from 'react'

import { Command, CommandInput, CommandList } from '@renderer/elements/Command'
import { CommandEmpty, CommandShortcut } from '@renderer/elements/Command'
Expand All @@ -7,16 +7,23 @@ import { Commands } from '@renderer/components/Commands'
import { CommandPage } from '@renderer/components/CommandPage'
import { CommandApplications } from '@renderer/components/CommandApplications'
import { CommandShortcuts } from '@renderer/components/CommandShortcuts'
import { useScrollToTop } from '@renderer/hooks'
import { usePluginSearch, usePluginToasts, useScrollToTop } from '@renderer/hooks'
import { Settings } from '@renderer/components/Settings'
import { Toaster } from 'sonner'

const App = () => {
const [selectedCommand, setSelectedCommand] = useState<CommandT | null>(null)
const [commandSearch, setCommandSearch] = useState('')
const commandListRef = useRef<HTMLDivElement | null>(null)
const [currentBangName, setCurrentBangName] = useState<string | null>(null)

const handlePluginClearSearch = useCallback(() => {
setCommandSearch('')
}, [])

usePluginToasts()
useScrollToTop(commandListRef, [commandSearch])
usePluginSearch(handlePluginClearSearch)
const handleInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault()
Expand All @@ -34,6 +41,8 @@ const App = () => {

return (
<div className="bg-black h-full">
<Toaster position="bottom-right" theme="dark" richColors />

{!selectedCommand && (
<Command filter={commandFilter} loop>
<div className="flex items-center gap-2 px-3 border-b border-zinc-800">
Expand Down
Loading