From 3db014c2dbb3042334b2f22016274ff0a9110875 Mon Sep 17 00:00:00 2001 From: Fritz Date: Wed, 27 May 2026 08:12:43 +1000 Subject: [PATCH 1/2] feat: implement app picker UI with application listing and selection functionality --- electron/main.cjs | 43 +++++++++++++ electron/preload.cjs | 1 + src/App.css | 80 ++++++++++++++++++++++++ src/App.jsx | 144 +++++++++++++++++++++++++++++++------------ 4 files changed, 229 insertions(+), 39 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index 44c4530..0cacf81 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -3,6 +3,7 @@ const { app, BrowserWindow, ipcMain, dialog, protocol, net, Tray, Menu, nativeImage } = require('electron') const path = require('path') const fs = require('fs') +const os = require('os') const { spawn, fork } = require('child_process') const { PLAYERCTL_COMMANDS, @@ -442,6 +443,48 @@ function registerIpcHandlers() { return result.canceled ? null : result.filePaths[0] }) + ipcMain.handle('apps:list', async () => { + const dirs = [ + '/usr/share/applications', + '/var/lib/flatpak/exports/share/applications', + path.join(os.homedir(), '.local/share/applications'), + path.join(os.homedir(), '.local/share/flatpak/exports/share/applications'), + ] + const apps = [] + const seen = new Set() + for (const dir of dirs) { + let files + try { files = await fs.promises.readdir(dir) } catch { continue } + for (const file of files) { + if (!file.endsWith('.desktop')) continue + const appId = file.replace(/\.desktop$/, '') + if (seen.has(appId)) continue + seen.add(appId) + try { + const content = await fs.promises.readFile(path.join(dir, file), 'utf8') + const data = {} + let inEntry = false + for (const line of content.split('\n')) { + const t = line.trim() + if (t === '[Desktop Entry]') { inEntry = true; continue } + if (t.startsWith('[') && t !== '[Desktop Entry]') { inEntry = false; continue } + if (!inEntry) continue + const eq = t.indexOf('=') + if (eq === -1) continue + const key = t.slice(0, eq).trim() + if (key.includes('[')) continue // skip localized keys + if (!data[key]) data[key] = t.slice(eq + 1).trim() + } + if (data.Type !== 'Application') continue + if (data.NoDisplay === 'true' || data.Hidden === 'true') continue + if (!data.Name) continue + apps.push({ name: data.Name, appId }) + } catch { continue } + } + } + return apps.sort((a, b) => a.name.localeCompare(b.name)) + }) + ipcMain.handle('profile:save', async (_, data) => { const p = getProfilePath(activeProfileName) await fs.promises.mkdir(path.dirname(p), { recursive: true }) diff --git a/electron/preload.cjs b/electron/preload.cjs index e7d9909..73cf302 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -23,6 +23,7 @@ contextBridge.exposeInMainWorld('streamDeck', { runCommand: (command) => ipcRenderer.invoke('action:run-cmd', { command }), sleepToggle: () => ipcRenderer.invoke('action:sleep-toggle'), browseForFile: () => ipcRenderer.invoke('dialog:open-file'), + listApps: () => ipcRenderer.invoke('apps:list'), browseIconDir: () => ipcRenderer.invoke('icons:browse-dir'), scanIconDir: (dirPath) => ipcRenderer.invoke('icons:scan-dir', { dirPath }), loadIconFile: (filePath) => ipcRenderer.invoke('icons:load-file', { filePath }), diff --git a/src/App.css b/src/App.css index 0a59ce1..a0498bd 100644 --- a/src/App.css +++ b/src/App.css @@ -1446,6 +1446,86 @@ font-style: italic; } +/* ─── App Picker ─────────────────────────────────────────── */ +.app-picker-selected { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: #1a2030; + border: 1px solid #2a3a55; + border-radius: 6px; + font-size: 13px; + color: #5aabff; +} + +.app-picker-selected-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-picker-clear-btn { + flex-shrink: 0; + background: none; + border: none; + color: #445; + cursor: pointer; + font-size: 12px; + padding: 0 0 0 8px; + line-height: 1; + transition: color 0.1s; +} + +.app-picker-clear-btn:hover { color: #ff6060; } + +.app-picker-dropdown { + display: flex; + flex-direction: column; + background: #161616; + border: 1px solid #2e2e2e; + border-radius: 6px; + overflow: hidden; + max-height: 220px; + overflow-y: auto; +} + +.app-picker-item { + background: none; + border: none; + border-bottom: 1px solid #1e1e1e; + text-align: left; + padding: 7px 10px; + font-size: 12px; + color: #aaa; + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.app-picker-item:last-child { border-bottom: none; } +.app-picker-item:hover { background: #1e2a3a; color: #fff; } + +.app-picker-no-results { + padding: 8px 10px; + font-size: 11px; + color: #444; + font-style: italic; +} + +.app-picker-switch-btn { + background: none; + border: none; + text-align: left; + padding: 2px 0; + font-size: 10px; + color: #3a3a3a; + cursor: pointer; + transition: color 0.1s; +} + +.app-picker-switch-btn:hover { color: #666; } + .action-hint { font-size: 11px; color: #3d3d3d; diff --git a/src/App.jsx b/src/App.jsx index 04ca45d..e21759d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -629,50 +629,116 @@ function HotkeyEditor({ value, onChange }) { // ─── Open App Editor ──────────────────────────────────────── function OpenAppEditor({ target, mode, onChange }) { + const isAdvanced = mode === 'direct' || mode === 'xdg-open' || (!!target && (target.startsWith('/') || target.startsWith('~') || target.includes(' '))) + const [advanced, setAdvanced] = useState(isAdvanced) + const [apps, setApps] = useState([]) + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + window.streamDeck?.listApps?.().then(list => { if (list?.length) setApps(list) }) + }, []) + + useEffect(() => { + if (!open) return + const handle = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) setOpen(false) } + document.addEventListener('mousedown', handle) + return () => document.removeEventListener('mousedown', handle) + }, [open]) + const browseFile = async () => { const file = await window.streamDeck?.browseForFile() - if (file) onChange({ target: file }) + if (file) { onChange({ target: file, mode: 'direct' }); setAdvanced(true) } } - return ( -
-
- onChange({ target: e.target.value })} - /> - -
-
- {[ - { value: 'gtk-launch', label: 'App ID', hint: 'Recommended — works for Flatpak & native' }, - { value: 'xdg-open', label: 'xdg-open', hint: 'Open files / URLs with default handler' }, - { value: 'direct', label: 'Command', hint: 'Run a binary or shell command directly' }, - ].map(opt => ( - - ))} + const FolderIcon = () => ( + + + + ) + + if (advanced) { + return ( +
+
+ onChange({ target: e.target.value })} + /> + +
+
+ {[ + { value: 'gtk-launch', label: 'App ID', hint: 'Recommended — works for Flatpak & native' }, + { value: 'xdg-open', label: 'xdg-open', hint: 'Open files / URLs with default handler' }, + { value: 'direct', label: 'Command', hint: 'Run a binary or shell command directly' }, + ].map(opt => ( + + ))} +
+
+ ) + } + + // ── Picker mode ── + const selectedApp = mode === 'gtk-launch' && target ? apps.find(a => a.appId === target) : null + const displayName = selectedApp?.name ?? (mode === 'gtk-launch' && target ? target : null) + const filtered = query.length > 0 + ? apps.filter(a => a.name.toLowerCase().includes(query.toLowerCase())).slice(0, 10) + : [] + + return ( +
+ {displayName && !open ? ( +
+ {displayName} + +
+ ) : ( +
+ { setQuery(e.target.value); setOpen(true) }} + onFocus={() => setOpen(true)} + /> + +
+ )} + + {open && ( +
+ {filtered.length > 0 && filtered.map(app => ( + + ))} + {filtered.length === 0 && query.length > 0 && ( +
No apps matching "{query}"
+ )} + {filtered.length === 0 && query.length === 0 && ( +
Start typing to search…
+ )} +
+ )} + +
) } From a9d20dfa904ea965574f89b95201e817a07075a0 Mon Sep 17 00:00:00 2001 From: Fritz Date: Wed, 27 May 2026 08:17:55 +1000 Subject: [PATCH 2/2] feat: add SOLID, KISS, and DRY refactor guidelines to SKILL.md --- .../skills/solid-kiss-dry-refactor/SKILL.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/skills/solid-kiss-dry-refactor/SKILL.md diff --git a/.github/skills/solid-kiss-dry-refactor/SKILL.md b/.github/skills/solid-kiss-dry-refactor/SKILL.md new file mode 100644 index 0000000..f7e9272 --- /dev/null +++ b/.github/skills/solid-kiss-dry-refactor/SKILL.md @@ -0,0 +1,95 @@ +--- +name: solid-kiss-dry-refactor +description: "Refactor code using SOLID, KISS, and DRY principles. Use when: improving code quality, reducing duplication, simplifying complex logic, splitting responsibilities, fixing over-engineering, extracting reusable abstractions, reviewing a file or module for design smells." +argument-hint: "File or module to refactor, or describe the code smell to fix" +--- + +# SOLID, KISS & DRY Refactor + +## When to Use +- A function or class has more than one reason to change (SRP violation) +- Logic is duplicated across two or more places (DRY violation) +- A function is hard to read or does too much at once (KISS violation) +- A class is modified every time a new variant is added (OCP violation) +- A caller depends on low-level details instead of an abstraction (DIP violation) +- Interfaces force implementors to define methods they don't use (ISP violation) + +--- + +## Principles Reference + +### SOLID +| Letter | Principle | Violation Signal | +|--------|-----------|-----------------| +| **S** | Single Responsibility — one reason to change | Class/function does fetching + parsing + rendering | +| **O** | Open/Closed — extend without modifying | Adding a new type requires editing a `switch` / `if-else` chain | +| **L** | Liskov Substitution — subtypes behave like base types | Override throws, ignores, or weakens a base-class contract | +| **I** | Interface Segregation — no forced unused methods | Interface has 10 methods but most implementors use 2 | +| **D** | Dependency Inversion — depend on abstractions | High-level module `new`s a concrete low-level class inline | + +### KISS +- Prefer the simplest solution that satisfies the requirements +- Avoid unnecessary layers of indirection, abstraction, or configuration +- Code should be readable by a new contributor without explanation + +### DRY +- Every piece of knowledge has one authoritative representation +- Identical logic in two places → extract to a shared function/module +- Identical structure in two places → consider a generic abstraction *only if* the duplication is genuine knowledge duplication, not coincidental similarity + +--- + +## Procedure + +### Step 1 — Analyze +1. Read the target file(s) in full. +2. List every violation found, tagged by principle (e.g. `[SRP]`, `[DRY]`, `[KISS]`). +3. Rate each violation: **High** (blocks extension / causes bugs), **Medium** (maintenance burden), **Low** (style preference). + +### Step 2 — Prioritise & Plan +1. Sort violations: High → Medium → Low. +2. For each High/Medium violation, describe the refactoring move (e.g. *extract function*, *introduce interface*, *inline variable*, *replace conditional with polymorphism*). +3. If changes affect more than one module or require a new abstraction, **present the plan to the user and get approval before writing code**. + +### Step 3 — Refactor (one violation at a time) +Apply refactors incrementally: +- **Extract function/method** — for KISS/SRP violations in functions > ~20 lines or doing > 1 thing. +- **Extract module/class** — for SRP violations at the class level. +- **Introduce abstraction / interface** — for OCP and DIP violations. +- **Deduplicate** — for DRY violations; create a shared utility/helper, then update all call sites. +- **Simplify** — for KISS violations; remove dead code, flatten nested logic, replace clever tricks with readable code. + +After each individual refactor: +- Confirm the code still compiles / has no type errors. +- Do not change behaviour — refactoring is behaviour-preserving. + +### Step 4 — Verify +1. Run the project's test suite (`npx vitest run` for this repo, or the relevant test command). +2. All tests must pass before marking done. +3. If tests break, fix them — the refactor changed a public API that tests depended on. + +### Step 5 — Summarise +Report: +- What violations were found +- What was changed and why +- Any trade-offs made (e.g. skipped a Low violation to avoid over-engineering) +- Whether tests pass + +--- + +## Decision Guardrails + +| Situation | Do | +|-----------|----| +| Two functions look similar but represent *different* concepts | Leave them separate (coincidental duplication) | +| Abstraction would only be used once | Don't introduce it (YAGNI / KISS) | +| Fixing an ISP/DIP violation requires a large interface redesign | Propose it, don't do it unilaterally | +| A violation is minor and touching it risks a regression | Note it, leave it, don't fix it | + +--- + +## Anti-Patterns to Avoid +- **Over-abstracting**: Adding factories, registries, or strategy patterns where a simple function suffices +- **Premature deduplication**: Merging two functions that happen to look the same but will diverge +- **Renaming without substance**: Improving names without fixing the structural issue +- **Splitting for splitting's sake**: Breaking a cohesive 30-line function into 5 micro-functions with no clarity gain