diff --git a/docs/design/homebrew-plugin/spec.md b/docs/design/homebrew-plugin/spec.md new file mode 100644 index 0000000..ab4def6 --- /dev/null +++ b/docs/design/homebrew-plugin/spec.md @@ -0,0 +1,500 @@ +# Design Spec: Homebrew Plugin for GQuick + +## 1. Overview + +The Homebrew plugin provides a dedicated management interface for Homebrew packages (formulae and casks) inside GQuick. It follows the **Docker plugin reference pattern**: a `brew:` launcher prefix, a full-page `HomebrewView` with tabs, and deep integration into the actions panel. + +**Platform restriction:** Homebrew is only available on **macOS and Linux**. On Windows, the plugin is hidden from the actions panel and its `shouldSearch` returns `false`. + +--- + +## 2. Files & Module Structure + +| File | Purpose | +|------|---------| +| `src/plugins/homebrew.tsx` | Plugin definition: `brew:` search, result items, actions | +| `src/components/HomebrewView.tsx` | Full-page view with tabs (Installed, Search, Outdated) | +| `src/utils/homebrewApi.ts` | Optional thin wrapper for `brew search --json`, `brew info --json` | + +--- + +## 3. Visual Design System + +### 3.1 Color Palette +Homebrew uses an **amber/orange** accent to differentiate from Docker's cyan. + +| Token | Value | Usage | +|-------|-------|-------| +| Primary accent | `text-amber-400` | Active tab, focused row border, action buttons | +| Primary bg | `bg-amber-500/10` | Selected states, badges, status chips | +| Primary border | `border-amber-500/20` | Cards, selected row borders, input focus ring | +| Primary button | `bg-amber-600 hover:bg-amber-500` | Install, Upgrade primary CTAs | +| Success | `text-emerald-400` | Successfully installed, up-to-date packages | +| Warning | `text-amber-300` | Outdated packages, warnings | +| Error / Destructive | `text-red-400`, `bg-red-500/10`, `border-red-500/20` | Uninstall, failed operations | +| Neutral (base) | `bg-zinc-950/95`, `bg-white/5`, `border-white/10`, `text-zinc-100/400/500` | Surfaces, borders, text — consistent with GQuick dark theme | + +### 3.2 Typography +- Page title: `text-sm font-medium text-zinc-100` +- Tab labels: `text-xs font-medium` +- Package name (list title): `text-[13px] font-medium text-zinc-100` +- Description / metadata: `text-[11px] text-zinc-500` +- Version badge: `text-[11px] font-mono` +- Status bar / toast: `text-xs` +- Monospace fields (tap names, paths): `text-xs font-mono text-zinc-400` + +### 3.3 Spacing System +- Base unit: 4px +- Page padding: `p-3` +- List row padding: `px-3 py-2.5` +- Tab bar padding: `p-2` +- Section gap: `gap-2` or `gap-3` +- Detail panel width: 280–320px (same as Docker, optional) + +### 3.4 Iconography +- Plugin icon: `Beer` from `lucide-react` (preferred). Fallback: `Package`. +- Actions panel: same `Beer` icon with `text-amber-400`. +- List item icon: `Package` (formula) / `Monitor` or `AppWindow` (cask) — small `h-4 w-4 text-zinc-500`. +- Status indicators: + - Installed: `CheckCircle2` in emerald + - Outdated: `AlertCircle` in amber + - Error: `XCircle` in red + +--- + +## 4. Component Hierarchy + +```text +HomebrewView +├── HeaderBar +│ ├── Title + Beer icon +│ ├── StatusChip (brew version, health) +│ └── SearchInput (global, persists across tabs) +├── TabBar +│ ├── "Installed" +│ ├── "Search" +│ └── "Outdated" +├── ContentArea +│ ├── InstalledTab +│ │ ├── PackageList +│ │ │ └── PackageRow (icon, name, version, date, tap, actions) +│ │ └── EmptyState / LoadingState +│ ├── SearchTab +│ │ ├── SearchInput (integrated with global, or uses global) +│ │ ├── PackageList (remote results) +│ │ └── EmptyState / LoadingState +│ └── OutdatedTab +│ ├── PackageList (current → latest version) +│ ├── BulkActions ("Upgrade All") +│ └── EmptyState / LoadingState +├── StatusBar (bottom) +│ ├── brew --version +│ ├── Last refreshed +│ └── Health / error message +└── ConfirmDialog (uninstall, upgrade all) +``` + +--- + +## 5. Tab Specifications + +### 5.1 Installed Tab +**Purpose:** Browse and manage locally installed formulae and casks. + +**Data displayed per row:** +- Icon: `Package` for formula, `Monitor` for cask +- Name: bold, `text-[13px]` +- Description: one-line truncate, `text-[11px] text-zinc-500` +- Version badge: `text-[11px] font-mono bg-white/5 rounded-full px-1.5 py-0.5` +- Installed date: `text-[11px] text-zinc-600` +- Tap: `text-[11px] font-mono text-zinc-500` (e.g., `homebrew/core`) +- Type badge: "formula" or "cask" pill + +**Row actions (kebab menu or inline buttons):** +- **Info** — open `brew info ` in detail panel or modal +- **Upgrade** — `brew upgrade ` (only if outdated; disabled otherwise) +- **Uninstall…** — triggers confirmation dialog + +**Empty state:** +> "No packages installed yet." +> Subtitle: "Homebrew is installed but your cellar is empty. Search and install packages from the Search tab." +> CTA button: "Go to Search" + +**Loading state:** +> Spinner + "Loading installed packages…" + +### 5.2 Search Tab +**Purpose:** Search for packages across Homebrew formulae and casks. + +**Search behavior:** +- Input at top of content area (or reuse global search input) +- Debounce: 300ms +- If query < 2 chars: show empty state or recent searches +- Query 2+ chars: run `brew search --json ` or API fallback + +**Data displayed per row:** +- Icon: `Package` / `Monitor` +- Name: bold +- Description: one-line truncate +- Version badge: latest stable version +- Install count (if available from API): `text-[11px] text-zinc-600` +- Tap name + +**Row actions:** +- **Install** — primary amber button; disabled if already installed +- **Info** — opens detail panel with full metadata + +**Empty state:** +> "No packages found for '{query}'." +> Subtitle: "Try a different search term or check the Homebrew naming conventions." + +**Loading state:** +> Spinner + "Searching Homebrew…" + +**Error state:** +> Amber banner: "Homebrew search failed. Check your connection and try again." +> Action: Retry button + +### 5.3 Outdated Tab +**Purpose:** Show packages with available updates. + +**Data displayed per row:** +- Icon + Name (same as Installed) +- Current version: `text-[11px] font-mono text-zinc-400` +- Arrow: `ArrowRight` icon `text-zinc-600` +- Latest version: `text-[11px] font-mono text-amber-400` +- Tap + +**Bulk actions (top of list):** +- **Upgrade All** — amber primary button; triggers confirmation dialog if >5 packages + +**Row actions:** +- **Upgrade** — upgrades single package +- **Info** — shows changelog or info if available + +**Empty state:** +> "All packages are up to date." +> Subtitle: "You're running the latest versions of all installed formulae and casks." +> Icon: `CheckCircle2` in emerald + +**Loading state:** +> Spinner + "Checking for outdated packages…" + +--- + +## 6. Interaction Flows + +### 6.1 Search Flow (Launcher) +1. User types `brew:` → top result: "Open Homebrew" with `Beer` icon, score 120 +2. User types `brew: ` → plugin searches: + - Local installed packages matching query (score 100) + - If query ≥ 2 chars, search remote via `brew search` (score 80) +3. Each result has actions array: + - Installed match: Uninstall, Upgrade (if outdated), Info + - Remote match: Install, Info +4. `onSelect` opens `HomebrewView` with relevant tab pre-selected + +### 6.2 Install Flow +1. User finds package in Search tab or launcher results +2. Clicks **Install** (or `onRun` from launcher action) +3. Button switches to spinner + "Installing…" +4. On success: + - Row updates (if in Search tab: button changes to "Installed" or disabled) + - Toast: "Installed ``" + - Installed tab auto-refreshes +5. On failure: + - Inline error on row or banner + - Button returns to "Install" + - Error message in status bar + +### 6.3 Uninstall Flow +1. User clicks **Uninstall…** +2. Confirmation dialog appears: + - Title: "Uninstall ``?" + - Description: "This will remove `` and all associated files. This action cannot be undone." + - Buttons: Cancel (secondary), Uninstall (red destructive) +3. On confirm: + - Row shows spinner + "Uninstalling…" + - On success: row removed from Installed tab, toast shown + - On failure: error banner + +### 6.4 Upgrade Flow +1. User clicks **Upgrade** on single package or **Upgrade All** +2. If Upgrade All and >5 packages: confirmation dialog + - Title: "Upgrade N packages?" + - Description: "This will upgrade all outdated formulae and casks." +3. During upgrade: + - Button disabled with spinner + - Status bar shows "Upgrading ``…" +4. On completion: + - Outdated tab refreshes (row removed if now current) + - Toast: "Upgraded `` to ``" + +### 6.5 Info Flow +1. User clicks **Info** +2. Detail panel or modal opens showing: + - Full name, description, homepage URL + - Versions (stable, head) + - Dependencies (runtime and build) + - Caveats (if any) + - Conflicts with + - Installation path / cellar path +3. Actions in detail panel: Open homepage, Copy install command + +--- + +## 7. Launcher Search Integration + +### Query Prefix +```ts +const BREW_PREFIX_PATTERN = /^brew\s*:/i; +``` + +### Result Items + +| Item | Score | Condition | +|------|-------|-----------| +| "Open Homebrew" | 120 | Always shown when prefix matches | +| Installed match | 100 | `brew list` result matches query | +| Remote match | 80 | `brew search` result, query ≥ 2 chars | + +### Top Result Behavior +When query is just `brew:` or `brew: ` (no search term): +- Show: "Open Homebrew" with subtitle "Type `brew: ` to search installed and remote packages." + +### Actions Panel Entry +Homebrew appears in the actions panel like Docker: +- Icon: `Beer` +- Color: `text-amber-400` +- Label: "Homebrew" +- Shortcut: `⌘ L⇧ B` / `Ctrl+Shift+B` +- `onClick`: opens Homebrew view + +--- + +## 8. App.tsx Integration Points + +### 8.1 View State +Add `"homebrew"` to the `view` union type: +```ts +type View = "search" | "chat" | "settings" | "actions" | "notes" | "docker" | "homebrew"; +``` + +### 8.2 Expanded Window +Add to `EXPANDED_WINDOW_VIEWS`: +```ts +homebrew: { width: 1200, height: 860 } +``` + +### 8.3 Shortcut Handler +Add `isHomebrewShortcut` function (mirrors `isDockerShortcut`): +```ts +function isHomebrewShortcut(e: KeyboardEvent, isLeftShiftPressed: boolean): boolean { + const isBKey = e.code === "KeyB" || e.key.toLowerCase() === "b"; + return (e.metaKey || e.ctrlKey) && e.shiftKey && isLeftShiftPressed && isBKey; +} +``` +Wire into global keydown handler: +```ts +if (isHomebrewShortcut(e, isLeftShiftPressedRef.current) && view !== "actions") { + e.preventDefault(); + setView(prev => prev === "homebrew" ? "search" : "homebrew"); + setQuery(""); + setHomebrewSearchQuery(""); +} +``` + +### 8.4 Actions Panel +Add to `appActions` array (before Docker to keep alphabetical or after): +```ts +{ id: "homebrew", label: "Homebrew", icon: Beer, shortcut: `${modKey} L⇧ B`, onClick: () => setView("homebrew") } +``` + +Note: Only render/include this action on macOS and Linux. On Windows, hide or disable. + +### 8.5 State & Event Listeners +- Add `homebrewSearchQuery` state + setter +- Add `homebrewInitialPackage` state (optional, for deep-linking from launcher) +- Add window event listener for `gquick-open-homebrew` +- Add window hidden cleanup for homebrew state + +### 8.6 Render View +Add `homebrew` branch in the view renderer: +```tsx +: view === "homebrew" ? ( + +) +``` + +--- + +## 9. Responsive Considerations + +The Homebrew view uses the same responsive strategy as Docker: + +| Breakpoint | Behavior | +|------------|----------| +| `>= 760px` (default) | Left tab sidebar (w-24 to w-32), main content, optional detail drawer | +| `560–759px` | Horizontal pill tabs at top; detail drawer overlays from right | +| `< 560px` | Single column; action menus remain keyboard-accessible; detail drawer becomes bottom sheet | + +**List rows:** Always full-width within content area. Action buttons collapse to kebab menu `⋯` on very narrow widths. + +**Search input:** Always visible at top of content area. On small screens, uses full width with reduced padding. + +--- + +## 10. Empty, Loading, and Error States + +### 10.1 Empty States + +| Tab | Message | Subtitle | CTA | +|-----|---------|----------|-----| +| Installed | "No packages installed yet." | "Your Homebrew cellar is empty." | "Go to Search" | +| Search (no query) | "Search Homebrew packages" | "Type a package name to find formulae and casks." | — | +| Search (no results) | "No packages found for '{query}'" | "Try a different search term." | — | +| Outdated | "All packages are up to date." | "You're running the latest versions." | — | + +**Empty state styling:** +- Centered flex layout +- Icon: `Search` / `Package` / `CheckCircle2` at `h-8 w-8 text-zinc-600` +- Title: `text-sm text-zinc-400` +- Subtitle: `text-[11px] text-zinc-600` +- CTA: amber primary button + +### 10.2 Loading States +- Global page load (first mount): `Loader2` spinner + "Loading Homebrew…" in center +- Tab switch / refresh: Subtle spinner in status bar + list rows show skeleton placeholders +- Search: Spinner in search input (right side) + "Searching Homebrew…" below input +- Action in progress: Button-level spinner, disabled state + +**Skeleton row styling:** +```text +rounded-xl border border-white/5 bg-white/[0.03] px-3 py-2.5 +├── pulse block (w-32 h-4 bg-white/5 rounded) // name +└── pulse block (w-48 h-3 bg-white/5 rounded mt-1.5) // subtitle +``` + +### 10.3 Error States + +| Scenario | Display | Recovery | +|----------|---------|----------| +| brew not in PATH | Full-page amber banner: "Homebrew not found. Install from brew.sh." | Link to brew.sh, Retry button | +| brew command fails | Inline banner in content area | Retry, dismiss | +| Search fails | Banner below search input | Retry search | +| Install/uninstall fails | Inline row error + status bar message | Retry action | +| Network issue (API) | Amber banner | Retry | + +**Error banner component:** +```text +rounded-xl border border-amber-500/20 bg-amber-500/5 p-3 +flex items-center gap-2 text-xs text-amber-100 +Icon: AlertTriangle +Text: error message +Action: Retry button (text-xs, amber) +``` + +--- + +## 11. Confirmation Dialogs + +### Uninstall Single Package +```text +┌─────────────────────────────────────────┐ +│ ⚠️ Uninstall ? │ +│ │ +│ This will remove and all │ +│ associated files. This cannot be │ +│ undone. │ +│ │ +│ [Cancel] [Uninstall] │ +│ (red destructive button) │ +└─────────────────────────────────────────┘ +``` + +### Upgrade All Packages +```text +┌─────────────────────────────────────────┐ +│ ⬆️ Upgrade N packages? │ +│ │ +│ This will upgrade all outdated │ +│ formulae and casks. │ +│ │ +│ [Cancel] [Upgrade All] │ +│ (amber primary button) │ +└─────────────────────────────────────────┘ +``` + +**Dialog styling:** Same as Docker's prune dialog: +- Backdrop: `fixed inset-0 bg-black/45 backdrop-blur-sm` +- Card: `rounded-2xl border border-white/10 bg-zinc-950/95 p-4 shadow-2xl` +- Title: `text-sm font-semibold text-zinc-100` +- Description: `text-xs text-zinc-400` +- Cancel: `border border-white/10 bg-white/5 text-zinc-300` +- Confirm: context-specific (red for destructive, amber for primary) + +--- + +## 12. Status Bar + +Fixed at bottom of `HomebrewView`: + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Homebrew 4.2.0 • 42 installed • 3 outdated • Ready │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Fields:** +- `brew --version` output (e.g., "Homebrew 4.2.0") +- Installed count +- Outdated count (amber if >0) +- Current operation status ("Ready", "Installing redis…", "Upgrading 3 packages…") +- Last refreshed timestamp + +**Styling:** +- Height: `h-8` +- Background: `border-t border-white/5 bg-white/[0.02]` +- Text: `text-[11px] text-zinc-500` +- Accent text: `text-amber-400` for outdated count + +--- + +## 13. Accessibility + +- **Keyboard navigation:** Tab through tabs with arrow keys; Enter to select; Space to activate buttons +- **Focus rings:** `focus:border-amber-500/40 focus:bg-white/10` on rows +- **ARIA:** + - Tab list: `role="tablist"`, tabs: `role="tab"`, panels: `role="tabpanel"` + - Search input: `aria-label="Search Homebrew packages"` + - Action buttons: `aria-label="Install "`, etc. + - Loading buttons: `aria-busy="true"` +- **Screen readers:** Status bar updates announced via `aria-live="polite"` +- **Color contrast:** All amber text on zinc backgrounds meets WCAG AA (amber-400 on zinc-950) + +--- + +## 14. Platform Considerations + +| Platform | Behavior | +|----------|----------| +| **macOS** | Full functionality. Native `brew` commands. | +| **Linux** | Full functionality (Linuxbrew). Same UI. | +| **Windows** | Plugin hidden from actions panel. `shouldSearch` returns `false`. If queried directly, return single result: "Homebrew is not available on Windows." | + +**Detection:** Check `navigator.platform` or use a Tauri command `is_homebrew_available` that checks `which brew`. + +--- + +## 15. Implementation Checklist + +- [ ] Create `src/plugins/homebrew.tsx` with `GQuickPlugin` export +- [ ] Create `src/components/HomebrewView.tsx` with tabbed interface +- [ ] Add `"homebrew"` to `View` union in `App.tsx` +- [ ] Add `homebrew` to `EXPANDED_WINDOW_VIEWS` +- [ ] Add `isHomebrewShortcut` and wire into keydown handler +- [ ] Add Homebrew to `appActions` array (platform-gated) +- [ ] Add `homebrewSearchQuery` state and view renderer +- [ ] Add window event `gquick-open-homebrew` +- [ ] Add cleanup in window-hidden listener +- [ ] Create `docs/design/homebrew-plugin/spec.md` (this document) diff --git a/docs/review/homebrew-plugin/findings.md b/docs/review/homebrew-plugin/findings.md new file mode 100644 index 0000000..4af4fde --- /dev/null +++ b/docs/review/homebrew-plugin/findings.md @@ -0,0 +1,347 @@ +# Homebrew Plugin — Detailed Code Review Findings + +**Reviewer:** Code Reviewer Agent +**Date:** 2026-05-09 +**Scope:** `src-tauri/src/lib.rs` (brew commands), `src/plugins/homebrew.tsx`, `src/components/HomebrewView.tsx`, `src/App.tsx`, `src/plugins/index.ts` +**Reference Pattern:** Docker plugin (`src/plugins/docker.tsx`) + +--- + +## [HB-001] Argument injection risk in `brew install` / `uninstall` / `upgrade` / `info` + +**Severity:** Medium +**Location:** `src-tauri/src/lib.rs` — `brew_install_blocking` (line 4415), `brew_uninstall_blocking` (line 4441), `brew_upgrade_blocking` (line 4472), `brew_info_blocking` (line 4237) + +### Description +User-supplied `name` values are appended directly to the `brew` argument list without a `--` separator. A crafted value starting with `-` (e.g. `--force`, `--version`) will be interpreted by Homebrew as a flag rather than a positional package name. While `Command::args()` prevents shell injection, it does not prevent argument injection. + +### Evidence +```rust +// brew_install_blocking (line 4415-4421) +let mut args = vec!["install".into()]; +if cask { + args.push("--cask".into()); +} +args.push(name); // <-- no "--" separator +brew_command_output(&args, Duration::from_secs(600)) +``` + +### Impact +A malicious or accidental package name beginning with `-` could alter Homebrew command behavior, bypass checks, or cause unexpected side effects. + +### Recommendation +Insert `"--".into()` before the user-supplied `name` in all commands that accept dynamic package names: +```rust +args.push("--".into()); +args.push(name); +``` + +--- + +## [HB-002] Misleading `AbortController` in search effect + +**Severity:** Medium +**Location:** `src/components/HomebrewView.tsx` — `useEffect` for search (lines 163-183) + +### Description +An `AbortController` is instantiated and its signal checked in the catch block, but `invoke()` does not accept an abort signal and the controller never actually cancels the in-flight Tauri command. The sequence-number guard (`searchSeq`) correctly prevents stale state updates, making the `AbortController` dead code that misleads future maintainers. + +### Evidence +```tsx +const controller = new AbortController() +// ... +try { + const results = await invoke("brew_search", { query: searchQuery }) + // invoke does not support AbortSignal +} catch (err) { + if (controller.signal.aborted || seq !== searchSeq.current) return + // controller is never triggered before the catch in practice +} +``` + +### Impact +Code that appears to support cancellation does not, leading to confusion and potential bugs if copied elsewhere. + +### Recommendation +Remove the `AbortController` and rely solely on the `searchSeq` guard (which is already correct), or add a comment explaining that Tauri `invoke` does not yet support request cancellation. + +--- + +## [HB-003] `require_confirmed` uses Docker-branded error helper + +**Severity:** Medium +**Location:** `src-tauri/src/lib.rs` — `require_confirmed` (line 2119) + +### Description +The shared confirmation helper was originally written for Docker and still calls `docker_err`. Homebrew commands that fail confirmation return error codes prefixed with Docker naming, which is confusing in logs and UI. + +### Evidence +```rust +fn require_confirmed(confirmed: Option, operation: &str) -> Result<(), String> { + if confirmed.unwrap_or(false) { + Ok(()) + } else { + Err(docker_err( + "CONFIRMATION_REQUIRED", + format!("{} requires explicit backend confirmation.", operation), + )) + } +} +``` + +### Impact +Inconsistent error branding; harder to debug Homebrew-specific issues. + +### Recommendation +Rename `docker_err` to a generic `command_err` (or `app_err`) and update all call sites (Docker + Homebrew). + +--- + +## [HB-004] No confirmation for install/upgrade in quick-search actions + +**Severity:** Medium +**Location:** `src/plugins/homebrew.tsx` — action handlers (lines 91, 154) + +### Description +In the launcher quick-search results, **Install** and **Upgrade** actions invoke `brew_install` / `brew_upgrade` immediately without user confirmation. The Docker plugin follows the same pattern for non-destructive ops, but Homebrew installs can take minutes, consume disk space, and install system-wide binaries. The full-page `HomebrewView` also upgrades without confirmation. + +### Evidence +```tsx +// line 91 — upgrade from installed list +onRun: () => { + void invoke("brew_upgrade", { name: p.name, cask: p.is_cask }) +}, + +// line 154 — install from search results +onRun: () => { + void invoke("brew_install", { name: r.name, cask: r.is_cask }) +}, +``` + +### Impact +Accidental one-click installation or upgrade of packages (including large casks) with no way to undo. + +### Recommendation +Consider requiring a `window.confirm()` for **Install** and **Upgrade** actions in the launcher, or at least add a visual delay / undo pattern. Match the level of friction to the operation's cost. + +--- + +## [HB-005] Deprecated `navigator.platform` for OS detection + +**Severity:** Low +**Location:** `src/plugins/homebrew.tsx` (line 28), `src/components/HomebrewView.tsx` (line 256) + +### Description +`navigator.platform` is deprecated and unreliable (e.g. it may return `"MacIntel"` on Apple Silicon under Rosetta, or be spoofed). The Rust backend already gates commands by `#[cfg]`, so the frontend check is only for UX short-circuiting, but it may give false positives/negatives. + +### Evidence +```tsx +const isUnsupportedPlatform = navigator.platform.toUpperCase().includes("WIN") +``` + +### Impact +Windows users on browsers that report a non-Windows platform might see a broken Homebrew UI; non-Windows users on spoofed platforms might see an unnecessary "not available" message. + +### Recommendation +Query the OS from Tauri (`@tauri-apps/api/os`) once at app startup and store it in a shared context, or remove the frontend guard entirely and let the backend’s platform-guarded errors drive the UX. + +--- + +## [HB-006] `localStorage` access without error handling + +**Severity:** Low +**Location:** `src/components/HomebrewView.tsx` — `initialDetailPanelWidth` (line 81-86), `useEffect` for width persistence (line 147-149) + +### Description +Reading and writing `localStorage` can throw in private-browsing modes (Safari) or when storage quota is exceeded. The code assumes these calls always succeed. + +### Evidence +```tsx +function initialDetailPanelWidth(): number { + const savedWidth = Number(localStorage.getItem(detailPanelWidthKey)) + // ... +} + +useEffect(() => { + localStorage.setItem(detailPanelWidthKey, String(detailPanelWidth)) +}, [detailPanelWidth]) +``` + +### Impact +A `QuotaExceededError` or `SecurityError` could crash the React render loop or effect cleanup. + +### Recommendation +Wrap `localStorage` calls in `try/catch` and fall back to the default width. + +--- + +## [HB-007] Dead keyboard resize handler in `DetailPanel` + +**Severity:** Low +**Location:** `src/components/HomebrewView.tsx` — `DetailPanel` resize handler (lines 559-564) + +### Description +The keyboard handler for the resizer swallows `ArrowLeft`, `ArrowRight`, and `End` keys (calls `event.preventDefault()`) but performs no action for them, leaving keyboard users without resize capability and trapping expected navigation behavior. + +### Evidence +```tsx +const resizeByKeyboard = (event: KeyboardEvent) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return + event.preventDefault() + if (event.key === "Home") onClose() + // Simplified: no width keyboard resize for brevity +} +``` + +### Impact +Keyboard users lose expected arrow-key behavior on the resizer element with no alternative provided. + +### Recommendation +Either implement width adjustment for arrow keys or remove them from the allowed-keys list so default browser behavior resumes. + +--- + +## [HB-008] Missing `type="button"` on action buttons + +**Severity:** Low +**Location:** `src/plugins/homebrew.tsx` — `ActionRow` (lines 206-216), `src/components/HomebrewView.tsx` — `PackageRows` (lines 512-527) and `DetailPanel` buttons + +### Description +Buttons rendered inside `ActionRow` and `PackageRows` lack `type="button"`. While they are not currently inside `
` elements, this is brittle; if they are ever wrapped in a form, they will submit it. + +### Evidence +```tsx + +``` + +### Recommendation +Add `type="button"` to all non-submit buttons. + +--- + +## [HB-009] Missing ARIA labels on icon-only buttons + +**Severity:** Low +**Location:** `src/components/HomebrewView.tsx` — `DetailPanel` close button (line 589) + +### Description +The close button in `DetailPanel` contains only an `` icon with no accessible label. Screen-reader users will hear "button" with no context. + +### Evidence +```tsx + +``` + +### Recommendation +Add `aria-label="Close detail panel"` (or similar) to the close button and review other icon-only buttons in the component. + +--- + +## [HB-010] Unused `_isCask` parameter in `selectPackage` + +**Severity:** Low +**Location:** `src/components/HomebrewView.tsx` — `selectPackage` (line 220) + +### Description +The `selectPackage` callback accepts `_isCask` but ignores it. This suggests an earlier design included cask-aware selection logic that was removed or never implemented. + +### Evidence +```tsx +const selectPackage = useCallback(async (name: string, _isCask: boolean) => { + try { + const info = await invoke("brew_info", { name }) + // ... + } +}, []) +``` + +### Impact +Misleading API; future callers may assume the parameter is used. + +### Recommendation +Remove the unused parameter or pass it to the `brew_info` invoke if it is needed for disambiguation. + +--- + +## [HB-011] `installed_on` field never populated + +**Severity:** Low +**Location:** `src-tauri/src/lib.rs` — `brew_list_blocking` (lines 4118, 4154) + +### Description +The `BrewPackage` struct includes `installed_on: Option`, but both formula and cask parsing branches set it to `None`. The field appears to be scaffolding for a future feature. + +### Evidence +```rust +packages.push(BrewPackage { + name: name.clone(), + version, + installed_on: None, // always None + tap, + // ... +}); +``` + +### Impact +Slight memory and serialization overhead; confusion for consumers expecting data. + +### Recommendation +Either populate the field from `installed[].date` (formula) / `installed` (cask) JSON fields, or remove it from the struct until needed. + +--- + +## [HB-012] Duplicate magic number for debounce + +**Severity:** Low +**Location:** `src/plugins/homebrew.tsx` (line 62), `src/components/HomebrewView.tsx` (line 170) + +### Description +The debounce delay of `300` ms is hardcoded in both the plugin metadata and the view component. If the plugin metadata changes, the view will drift out of sync. + +### Evidence +```tsx +// homebrew.tsx +searchDebounceMs: 300, + +// HomebrewView.tsx +const timer = window.setTimeout(async () => { /* ... */ }, 300) +``` + +### Recommendation +Export the debounce value from the plugin (or a shared constants file) and consume it in the view. + +--- + +## [HB-013] `BrewSearchResult.installed` always `false` + +**Severity:** Low +**Location:** `src-tauri/src/lib.rs` — `brew_search_blocking` (lines 4196-4218) + +### Description +`brew search --json` does not return installation status, so the code hardcodes `installed: false` for every search result. This is expected behavior from Homebrew, but the field name implies it may be meaningful to consumers. + +### Evidence +```rust +results.push(BrewSearchResult { + name: name.to_string(), + description: None, + version: None, + tap: "homebrew/core".into(), + is_cask: false, + installed: false, // always false +}); +``` + +### Impact +Frontend consumers might display inaccurate "not installed" labels. + +### Recommendation +Either cross-reference with `brew_list` results to set the flag accurately, or remove the field and let the frontend infer installation status when the user navigates to package details. diff --git a/docs/review/homebrew-plugin/summary.md b/docs/review/homebrew-plugin/summary.md new file mode 100644 index 0000000..ae6d611 --- /dev/null +++ b/docs/review/homebrew-plugin/summary.md @@ -0,0 +1,77 @@ +# Code Review Summary: Homebrew Plugin + +**Reviewer:** Code Reviewer Agent +**Date:** 2026-05-09 +**Status:** Needs Changes +**Build Status:** ✅ `npx tsc --noEmit` clean, ✅ `cargo check` clean + +--- + +## Overall Assessment + +The Homebrew plugin is a solid, well-structured addition that closely mirrors the existing Docker plugin patterns. The Rust backend correctly uses platform guards (`#[cfg(any(target_os = "macos", target_os = "linux"))]`), timeouts, and the shared `run_blocking` mechanism. The frontend implements proper race-condition guards (`refreshSeq`, `searchSeq`), loading states, and confirmation dialogs for destructive operations. + +However, several security, accessibility, and code-quality issues should be addressed before the feature is considered production-ready. None are catastrophic, but the argument-injection risk and misleading cancellation code warrant attention. + +--- + +## Critical Issues (0) + +None. + +--- + +## High Issues (0) + +None. + +--- + +## Medium Issues (4) + +| ID | Title | File | +|---|---|---| +| HB-001 | Argument injection risk — no `--` separator before user-supplied package names | `src-tauri/src/lib.rs` | +| HB-002 | Misleading `AbortController` in search effect (dead code) | `src/components/HomebrewView.tsx` | +| HB-003 | `require_confirmed` uses Docker-branded error helper | `src-tauri/src/lib.rs` | +| HB-004 | No confirmation for install/upgrade in quick-search actions | `src/plugins/homebrew.tsx` | + +--- + +## Low Issues (9) + +| ID | Title | File | +|---|---|---| +| HB-005 | Deprecated `navigator.platform` for OS detection | `src/plugins/homebrew.tsx`, `src/components/HomebrewView.tsx` | +| HB-006 | `localStorage` access without error handling | `src/components/HomebrewView.tsx` | +| HB-007 | Dead keyboard resize handler in `DetailPanel` | `src/components/HomebrewView.tsx` | +| HB-008 | Missing `type="button"` on action buttons | `src/plugins/homebrew.tsx`, `src/components/HomebrewView.tsx` | +| HB-009 | Missing ARIA labels on icon-only buttons | `src/components/HomebrewView.tsx` | +| HB-010 | Unused `_isCask` parameter in `selectPackage` | `src/components/HomebrewView.tsx` | +| HB-011 | `installed_on` field never populated | `src-tauri/src/lib.rs` | +| HB-012 | Duplicate magic number for debounce | `src/plugins/homebrew.tsx`, `src/components/HomebrewView.tsx` | +| HB-013 | `BrewSearchResult.installed` always `false` | `src-tauri/src/lib.rs` | + +--- + +## Positive Findings + +- **Platform guards are correct.** Both compile-time (`#[cfg]`) and runtime guards prevent Homebrew commands from running on Windows. +- **Race-condition handling is thorough.** `refreshSeq` and `searchSeq` prevent stale state updates. +- **Timeouts are reasonable.** Install/upgrade get 10 minutes, uninstall gets 2 minutes, search gets 30 seconds. +- **Confirmation pattern is followed.** Uninstall and "upgrade all" require backend confirmation via `require_confirmed`. +- **Error handling is graceful.** The launcher plugin catches `brew_list` and `brew_search` failures independently and shows fallback items. +- **Consistent with Docker plugin.** Structure, patterns, and file organization match the reference implementation. +- **Build is clean.** TypeScript and Rust both compile without errors. + +--- + +## Recommendations + +1. **Fix HB-001** (argument injection) by adding `"--"` before user-supplied package names in all `brew_*_blocking` functions. +2. **Fix HB-002** by removing the ineffective `AbortController` or adding a clarifying comment. +3. **Address HB-003** by renaming `docker_err` to a generic helper (affects both Docker and Homebrew). +4. **Consider HB-004** — decide whether install/upgrade in the launcher should show a confirmation dialog. +5. **Batch-fix low-severity items** (HB-005 through HB-013) in a single polish pass. + +**Verdict:** Merge after addressing medium-severity issues (HB-001 through HB-004). Low-severity items can be deferred to a follow-up cleanup PR. diff --git a/memories/project_context.md b/memories/project_context.md index 735b864..caeae21 100644 --- a/memories/project_context.md +++ b/memories/project_context.md @@ -69,7 +69,7 @@ Current AI tools: `calculate`, `search_files`, `read_file`, `search_notes`, `cre - Recent files: usage tracker records selections in `localStorage` with exponential decay scoring; `recentFilesPlugin` queries `getRecentItemsByPlugin("file-search", 5)` to surface recent files/folders above filesystem results. - Notes: quick capture/search/plugin tools/NotesView → Rust note commands → SQLite `notes` table. - Docker: `docker:` plugin and DockerView → Rust Docker commands → validated Docker CLI/Compose operations; Docker Hub search also available through frontend utility and Rust command. -- Screenshot/OCR: global shortcut → selector window → `capture_region` → xcap crop/save → clipboard image or OCR text/event/base64 for AI vision. +- Screenshot/OCR: global shortcut → selector window → `capture_region` → xcap crop/save to app data dir (`gquick_capture.png`) → clipboard image or OCR text/event/base64 for AI vision. ## Technology Stack @@ -93,6 +93,8 @@ Architecture documentation was validated and refreshed for overall functionality **Recent update:** Added `recentFilesPlugin` (immediate plugin, no debounce) that surfaces recently opened files/folders from usage history stored in `localStorage`. Results are deduplicated with debounced plugins and ranked with high score (200) to appear above filesystem scan results. `SearchSuggestions` now shows file-search entries in the unified "Recent" section alongside apps. +**Recent update:** Screenshot capture (`gquick_capture.png`) now saves to the app data directory instead of Desktop. Actions panel cleaned up to show only one trigger per plugin (not all regex alternatives). Plugin keywords trimmed to only words that actually trigger the plugin. macOS app launcher now includes `/Applications/Utilities` and `/System/Applications/Utilities` to discover system utilities (Terminal, Activity Monitor, etc.). + ## Key Decisions - Keep plugin registry explicit and ordered for predictable result priority. diff --git a/package.json b/package.json index 51aa9ac..7afd58c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gquick", "private": true, - "version": "0.1.8", + "version": "0.1.9", "type": "module", "engines": { "node": ">=24.15.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc3f99d..8f3470b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "gquick" -version = "0.1.8" +version = "0.1.9" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 069c116..a186df5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gquick" -version = "0.1.8" +version = "0.1.9" description = "A simple, fast and feature rich launcher" authors = ["Ricky Dane Perlick"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e02984..4bdfb55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -725,7 +725,7 @@ where { tauri::async_runtime::spawn_blocking(operation) .await - .map_err(|e| docker_err("COMMAND_FAILED", format!("Background task failed: {e}"))) + .map_err(|e| command_err("COMMAND_FAILED", format!("Background task failed: {e}"))) } struct ShortcutState { @@ -768,6 +768,138 @@ struct TerminalState { inline_processes: Arc>>>>, } +#[derive(Clone, serde::Serialize)] +struct BrewOperationEvent { + chunk: String, + seq: u64, + done: bool, + success: bool, +} + +#[derive(serde::Serialize)] +struct BrewOperationStatus { + running: bool, + command: Option, + output: String, +} + +#[derive(Clone)] +struct BrewOperationManager { + running: Arc>, + command: Arc>>, + output: Arc>, + child: Arc>>, + seq: Arc>, + op_id: Arc>, +} + +impl BrewOperationManager { + fn new() -> Self { + Self { + running: Arc::new(Mutex::new(false)), + command: Arc::new(Mutex::new(None)), + output: Arc::new(Mutex::new(String::new())), + child: Arc::new(Mutex::new(None)), + seq: Arc::new(Mutex::new(0)), + op_id: Arc::new(Mutex::new(0)), + } + } + + fn next_op_id(&self) -> u64 { + if let Ok(mut op_id) = self.op_id.lock() { + *op_id += 1; + *op_id + } else { + 0 + } + } + + fn start(&self, command: String) -> Result { + let mut running = self.running.lock().map_err(|e| e.to_string())?; + if *running { + return Err("A Homebrew operation is already running.".into()); + } + *running = true; + drop(running); + + let mut cmd = self.command.lock().map_err(|e| e.to_string())?; + *cmd = Some(command); + drop(cmd); + + let mut output = self.output.lock().map_err(|e| e.to_string())?; + output.clear(); + drop(output); + + let mut child = self.child.lock().map_err(|e| e.to_string())?; + *child = None; + + let mut seq = self.seq.lock().map_err(|e| e.to_string())?; + *seq = 0; + + let op_id = self.next_op_id(); + Ok(op_id) + } + + fn append_output(&self, op_id: u64, chunk: &str) { + if let Ok(current_op_id) = self.op_id.lock() { + if *current_op_id != op_id { + return; + } + } + if let Ok(mut output) = self.output.lock() { + output.push_str(chunk); + } + } + + fn next_seq(&self) -> u64 { + if let Ok(mut seq) = self.seq.lock() { + *seq += 1; + *seq + } else { + 0 + } + } + + fn finish(&self, op_id: u64, _success: bool) { + if let Ok(current_op_id) = self.op_id.lock() { + if *current_op_id != op_id { + return; + } + } + if let Ok(mut running) = self.running.lock() { + *running = false; + } + if let Ok(mut child) = self.child.lock() { + *child = None; + } + } + + fn status(&self) -> Result { + let running = self.running.lock().map_err(|e| e.to_string())?; + let command = self.command.lock().map_err(|e| e.to_string())?; + let output = self.output.lock().map_err(|e| e.to_string())?; + Ok(BrewOperationStatus { + running: *running, + command: command.clone(), + output: output.clone(), + }) + } + + fn cancel(&self) -> Result<(), String> { + let mut child = self.child.lock().map_err(|e| e.to_string())?; + if let Some(mut c) = child.take() { + terminate_process_tree(&mut c); + let _ = c.wait(); + } + // Do not clear running or advance op_id here; let the worker's finish() do it. + Ok(()) + } +} + +struct BrewOperationState { + manager: BrewOperationManager, +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct TerminalCommandResult { @@ -2026,7 +2158,7 @@ async fn search_docker_hub( let normalized = query.trim(); if normalized.is_empty() { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", "Docker Hub query cannot be empty.".into(), )); @@ -2037,10 +2169,10 @@ async fn search_docker_hub( .timeout(Duration::from_secs(10)) .user_agent("GQuick/0.1 DockerHubSearch") .build() - .map_err(|e| docker_err("DOCKER_HUB_CLIENT_FAILED", e.to_string()))?; + .map_err(|e| command_err("DOCKER_HUB_CLIENT_FAILED", e.to_string()))?; let mut url = reqwest::Url::parse("https://hub.docker.com/v2/search/repositories/") - .map_err(|e| docker_err("DOCKER_HUB_URL_FAILED", e.to_string()))?; + .map_err(|e| command_err("DOCKER_HUB_URL_FAILED", e.to_string()))?; url.query_pairs_mut() .append_pair("query", normalized) .append_pair("page_size", &page_size.to_string()); @@ -2049,11 +2181,11 @@ async fn search_docker_hub( .get(url) .send() .await - .map_err(|e| docker_err("DOCKER_HUB_REQUEST_FAILED", e.to_string()))?; + .map_err(|e| command_err("DOCKER_HUB_REQUEST_FAILED", e.to_string()))?; let status = response.status(); if !status.is_success() { - return Err(docker_err( + return Err(command_err( "DOCKER_HUB_REQUEST_FAILED", format!("Docker Hub returned HTTP {}.", status.as_u16()), )); @@ -2062,7 +2194,7 @@ async fn search_docker_hub( let data = response .json::() .await - .map_err(|e| docker_err("DOCKER_HUB_PARSE_FAILED", e.to_string()))?; + .map_err(|e| command_err("DOCKER_HUB_PARSE_FAILED", e.to_string()))?; Ok(data .results @@ -2102,13 +2234,13 @@ fn normalize_docker_hub_repository(item: DockerHubApiRepository) -> DockerHubRep } } -fn docker_err(code: &str, message: String) -> String { +fn command_err(code: &str, message: String) -> String { format!("{}: {}", code, message.trim()) } fn validate_ref(value: &str, field: &str) -> Result<(), String> { if value.trim().is_empty() || value.contains('\0') || value.len() > 512 { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", format!("Invalid {}.", field), )); @@ -2120,7 +2252,7 @@ fn require_confirmed(confirmed: Option, operation: &str) -> Result<(), Str if confirmed.unwrap_or(false) { Ok(()) } else { - Err(docker_err( + Err(command_err( "CONFIRMATION_REQUIRED", format!("{} requires explicit backend confirmation.", operation), )) @@ -3325,7 +3457,7 @@ fn hide_main_window(window: tauri::Window) -> Result<(), String> { fn docker_output(args: &[String]) -> Result { let status = docker_status_blocking(); if !status.cli_installed { - return Err(docker_err( + return Err(command_err( "CLI_MISSING", status .error_message @@ -3333,7 +3465,7 @@ fn docker_output(args: &[String]) -> Result { )); } if !status.daemon_running { - return Err(docker_err( + return Err(command_err( "DAEMON_DOWN", status .error_message @@ -3346,7 +3478,7 @@ fn docker_output(args: &[String]) -> Result { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() - .map_err(|e| docker_err("COMMAND_FAILED", e.to_string()))?; + .map_err(|e| command_err("COMMAND_FAILED", e.to_string()))?; let stdout_handle = child.stdout.take().map(read_pipe); let stderr_handle = child.stderr.take().map(read_pipe); @@ -3356,14 +3488,14 @@ fn docker_output(args: &[String]) -> Result { let status_code = loop { if let Some(status_code) = child .try_wait() - .map_err(|e| docker_err("COMMAND_FAILED", e.to_string()))? + .map_err(|e| command_err("COMMAND_FAILED", e.to_string()))? { break status_code; } if started.elapsed() > timeout { let _ = child.kill(); let _ = child.wait(); - return Err(docker_err("TIMEOUT", format!("Docker command timed out after {} seconds. Long operations may still be running in Docker; retry or check Docker Desktop/CLI output.", timeout.as_secs()))); + return Err(command_err("TIMEOUT", format!("Docker command timed out after {} seconds. Long operations may still be running in Docker; retry or check Docker Desktop/CLI output.", timeout.as_secs()))); } std::thread::sleep(Duration::from_millis(100)); }; @@ -3371,22 +3503,22 @@ fn docker_output(args: &[String]) -> Result { let stdout = match stdout_handle { Some(handle) => handle .join() - .map_err(|_| docker_err("COMMAND_FAILED", "Failed to read Docker stdout.".into()))? - .map_err(|e| docker_err("COMMAND_FAILED", e))?, + .map_err(|_| command_err("COMMAND_FAILED", "Failed to read Docker stdout.".into()))? + .map_err(|e| command_err("COMMAND_FAILED", e))?, None => String::new(), }; let stderr = match stderr_handle { Some(handle) => handle .join() - .map_err(|_| docker_err("COMMAND_FAILED", "Failed to read Docker stderr.".into()))? - .map_err(|e| docker_err("COMMAND_FAILED", e))?, + .map_err(|_| command_err("COMMAND_FAILED", "Failed to read Docker stderr.".into()))? + .map_err(|e| command_err("COMMAND_FAILED", e))?, None => String::new(), }; let result = DockerCommandResult { stdout, stderr }; if !status_code.success() { - return Err(docker_err( + return Err(command_err( "COMMAND_FAILED", if result.stderr.trim().is_empty() { result.stdout.clone() @@ -3503,7 +3635,7 @@ fn manage_container_blocking( "start", "stop", "restart", "pause", "unpause", "remove", "kill", ]; if !allowed.contains(&action.as_str()) { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", format!("Unsupported container action: {}", action), )); @@ -3557,7 +3689,7 @@ fn run_container_blocking(options: RunContainerOptions) -> Result Result Result { validate_ref(&id, "container id")?; if command.is_empty() { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", "Command is required.".into(), )); @@ -3692,7 +3824,7 @@ fn prune_docker_blocking( "volumes" => vec!["volume".into(), "prune".into()], "system" => vec!["system".into(), "prune".into()], _ => { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", format!("Unsupported prune kind: {}", kind), )) @@ -3727,13 +3859,13 @@ fn is_safe_compose_name(path: &Path) -> bool { fn validate_compose_path_for_read(path: &str) -> Result { let path_ref = Path::new(path); if path.trim().is_empty() || path.contains('\0') || !is_safe_compose_name(path_ref) { - return Err(docker_err("VALIDATION_FAILED", "Compose file path must end with docker-compose.yml/yaml, compose.yml/yaml, or another *compose*.yml/yaml file.".into())); + return Err(command_err("VALIDATION_FAILED", "Compose file path must end with docker-compose.yml/yaml, compose.yml/yaml, or another *compose*.yml/yaml file.".into())); } let canonical = path_ref .canonicalize() - .map_err(|_| docker_err("VALIDATION_FAILED", "Compose file does not exist.".into()))?; + .map_err(|_| command_err("VALIDATION_FAILED", "Compose file does not exist.".into()))?; if !canonical.is_file() { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", "Compose file does not exist.".into(), )); @@ -3744,32 +3876,32 @@ fn validate_compose_path_for_read(path: &str) -> Result { fn validate_compose_path_for_write(path: &str) -> Result { let path_ref = Path::new(path); if path.trim().is_empty() || path.contains('\0') || !is_safe_compose_name(path_ref) { - return Err(docker_err("VALIDATION_FAILED", "Compose file path must end with docker-compose.yml/yaml, compose.yml/yaml, or another *compose*.yml/yaml file.".into())); + return Err(command_err("VALIDATION_FAILED", "Compose file path must end with docker-compose.yml/yaml, compose.yml/yaml, or another *compose*.yml/yaml file.".into())); } if path_ref .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", "Compose file path cannot contain parent-directory traversal.".into(), )); } let parent = path_ref.parent().ok_or_else(|| { - docker_err( + command_err( "VALIDATION_FAILED", "Compose file parent directory is required.".into(), ) })?; let canonical_parent = parent.canonicalize().map_err(|_| { - docker_err( + command_err( "VALIDATION_FAILED", "Compose file parent directory does not exist.".into(), ) })?; Ok(canonical_parent.join( path_ref.file_name().ok_or_else(|| { - docker_err("VALIDATION_FAILED", "Compose file name is required.".into()) + command_err("VALIDATION_FAILED", "Compose file name is required.".into()) })?, )) } @@ -3781,7 +3913,7 @@ async fn compose_read_file(path: String) -> Result { fn compose_read_file_blocking(path: String) -> Result { let path_ref = validate_compose_path_for_read(&path)?; - std::fs::read_to_string(path_ref).map_err(|e| docker_err("COMMAND_FAILED", e.to_string())) + std::fs::read_to_string(path_ref).map_err(|e| command_err("COMMAND_FAILED", e.to_string())) } #[tauri::command] @@ -3803,14 +3935,14 @@ fn compose_write_file_blocking( let path_ref = validate_compose_path_for_write(&path)?; if path_ref.exists() { if !overwrite.unwrap_or(false) { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", "File exists; confirm overwrite first.".into(), )); } require_confirmed(confirmed, "Overwriting compose files")?; } - std::fs::write(path_ref, content).map_err(|e| docker_err("COMMAND_FAILED", e.to_string())) + std::fs::write(path_ref, content).map_err(|e| command_err("COMMAND_FAILED", e.to_string())) } #[tauri::command] @@ -3852,7 +3984,7 @@ fn compose_action_blocking( } "pull" | "logs" | "ps" | "restart" => {} _ => { - return Err(docker_err( + return Err(command_err( "VALIDATION_FAILED", format!("Unsupported compose action: {}", action), )) @@ -3861,6 +3993,888 @@ fn compose_action_blocking( docker_output(&args) } +fn brew_err(code: &str, message: String) -> String { + format!("{}: {}", code, message.trim()) +} + +#[derive(serde::Serialize)] +struct BrewStatus { + installed: bool, + version: Option, + prefix: Option, + error: Option, +} + +#[derive(serde::Serialize)] +struct BrewPackage { + name: String, + version: String, + tap: String, + desc: Option, + homepage: Option, + is_cask: bool, + outdated: bool, + latest_version: Option, +} + +#[derive(serde::Serialize)] +struct BrewSearchResult { + name: String, + description: Option, + version: Option, + tap: String, + is_cask: bool, +} + +#[derive(serde::Serialize)] +struct BrewInfo { + name: String, + description: Option, + homepage: Option, + versions: Versions, + installed: Vec, + caveats: Option, + dependencies: Vec, + tap: String, + is_cask: bool, +} + +#[derive(serde::Serialize)] +struct Versions { + stable: Option, + head: Option, +} + +#[derive(serde::Serialize)] +struct InstalledVersion { + version: String, + installed_as_dependency: bool, + installed_on_request: bool, +} + +#[derive(serde::Serialize)] +struct BrewCommandResult { + stdout: String, + stderr: String, + success: bool, +} + +#[derive(serde::Serialize)] +struct BrewOutdatedPackage { + name: String, + installed_version: String, + current_version: String, + is_cask: bool, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_cli_installed() -> bool { + Command::new("brew") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_command_output(args: &[String], timeout: Duration) -> Result { + if !brew_cli_installed() { + return Err(brew_err( + "CLI_MISSING", + "Homebrew is not installed or not on PATH.".into(), + )); + } + let mut child = Command::new("brew") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| brew_err("COMMAND_FAILED", e.to_string()))?; + let stdout_handle = child.stdout.take().map(read_pipe); + let stderr_handle = child.stderr.take().map(read_pipe); + let started = Instant::now(); + let status_code = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() <= timeout => { + std::thread::sleep(Duration::from_millis(100)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err(brew_err( + "TIMEOUT", + format!( + "Homebrew command timed out after {} seconds.", + timeout.as_secs() + ), + )); + } + } + }; + let stdout = match stdout_handle { + Some(handle) => handle + .join() + .map_err(|_| brew_err("COMMAND_FAILED", "Failed to read stdout.".into()))? + .map_err(|e| brew_err("COMMAND_FAILED", e))?, + None => String::new(), + }; + let stderr = match stderr_handle { + Some(handle) => handle + .join() + .map_err(|_| brew_err("COMMAND_FAILED", "Failed to read stderr.".into()))? + .map_err(|e| brew_err("COMMAND_FAILED", e))?, + None => String::new(), + }; + Ok(BrewCommandResult { + stdout, + stderr, + success: status_code.success(), + }) +} + +#[tauri::command] +async fn brew_status() -> BrewStatus { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + BrewStatus { + installed: false, + version: None, + prefix: None, + error: Some("Homebrew is not available on this platform.".into()), + } + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + match run_blocking(brew_status_blocking).await { + Ok(Ok(status)) => status, + Ok(Err(error)) | Err(error) => BrewStatus { + installed: false, + version: None, + prefix: None, + error: Some(error), + }, + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_status_blocking() -> Result { + let version_output = Command::new("brew").arg("--version").output(); + let (installed, version) = match version_output { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + let version = text.lines().next().map(|l| l.trim().to_string()); + (true, version) + } + _ => (false, None), + }; + let prefix = Command::new("brew") + .arg("--prefix") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }); + let error = if installed { + None + } else { + Some("Homebrew is not installed or not on PATH.".into()) + }; + Ok(BrewStatus { + installed, + version, + prefix, + error, + }) +} + +#[tauri::command] +async fn brew_list() -> Result, String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + run_blocking(brew_list_blocking).await? + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_list_blocking() -> Result, String> { + let args = vec!["info".into(), "--installed".into(), "--json=v2".into()]; + let result = brew_command_output(&args, Duration::from_secs(120))?; + if !result.success { + return Err(brew_err("COMMAND_FAILED", result.stderr)); + } + let json: serde_json::Value = serde_json::from_str(&result.stdout) + .map_err(|e| brew_err("PARSE_FAILED", e.to_string()))?; + let mut packages = Vec::new(); + if let Some(formulae) = json.get("formulae").and_then(|v| v.as_array()) { + for f in formulae { + let name = f.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let tap = f + .get("tap") + .and_then(|v| v.as_str()) + .unwrap_or("homebrew/core") + .to_string(); + let desc = f.get("desc").and_then(|v| v.as_str()).map(|s| s.to_string()); + let homepage = f + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let version = f + .get("installed") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("version")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let outdated = f.get("outdated").and_then(|v| v.as_bool()).unwrap_or(false); + let latest = f + .get("versions") + .and_then(|v| v.get("stable")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + packages.push(BrewPackage { + name: name.clone(), + version, + tap, + desc, + homepage, + is_cask: false, + outdated, + latest_version: latest, + }); + } + } + if let Some(casks) = json.get("casks").and_then(|v| v.as_array()) { + for c in casks { + let name = c.get("token").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let tap = c + .get("tap") + .and_then(|v| v.as_str()) + .unwrap_or("homebrew/cask") + .to_string(); + let desc = c.get("desc").and_then(|v| v.as_str()).map(|s| s.to_string()); + let homepage = c + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let version = c + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let outdated = c.get("outdated").and_then(|v| v.as_bool()).unwrap_or(false); + let latest = c + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + packages.push(BrewPackage { + name: name.clone(), + version, + tap, + desc, + homepage, + is_cask: true, + outdated, + latest_version: latest, + }); + } + } + Ok(packages) +} + +#[tauri::command] +async fn brew_search(query: String) -> Result, String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + run_blocking(move || brew_search_blocking(query)).await? + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_search_blocking(query: String) -> Result, String> { + let trimmed = query.trim(); + if trimmed.is_empty() { + return Err(brew_err("VALIDATION_FAILED", "Query cannot be empty.".into())); + } + let args = vec!["search".into(), trimmed.into()]; + let result = brew_command_output(&args, Duration::from_secs(30))?; + if !result.success { + return Err(brew_err("COMMAND_FAILED", result.stderr)); + } + + let mut results = Vec::new(); + let mut section = "formulae"; // "formulae" or "casks" + + for line in result.stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if line.to_lowercase().contains("cask") { + section = "casks"; + continue; + } + if line.starts_with("==") { + continue; + } + // Skip lines that look like tap paths (e.g., "homebrew/cask-fonts/") + if line.ends_with("/") || (line.contains("/") && !line.chars().next().unwrap_or(' ').is_alphanumeric()) { + continue; + } + + let is_cask = section == "casks"; + results.push(BrewSearchResult { + name: line.to_string(), + description: None, + version: None, + tap: if is_cask { "homebrew/cask".into() } else { "homebrew/core".into() }, + is_cask, + }); + } + + Ok(results) +} + +#[tauri::command] +async fn brew_info(name: String) -> Result { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + run_blocking(move || brew_info_blocking(name)).await? + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_info_blocking(name: String) -> Result { + let args = vec!["info".into(), "--json=v2".into(), "--".into(), name.clone()]; + let result = brew_command_output(&args, Duration::from_secs(60))?; + if !result.success { + return Err(brew_err("COMMAND_FAILED", result.stderr)); + } + let json: serde_json::Value = serde_json::from_str(&result.stdout) + .map_err(|e| brew_err("PARSE_FAILED", e.to_string()))?; + if let Some(formulae) = json.get("formulae").and_then(|v| v.as_array()) { + if let Some(f) = formulae.first() { + return parse_formula_info(f); + } + } + if let Some(casks) = json.get("casks").and_then(|v| v.as_array()) { + if let Some(c) = casks.first() { + return parse_cask_info(c); + } + } + Err(brew_err("NOT_FOUND", format!("Package {} not found.", name))) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn parse_formula_info(value: &serde_json::Value) -> Result { + let name = value.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let description = value.get("desc").and_then(|v| v.as_str()).map(|s| s.to_string()); + let homepage = value + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let stable = value + .get("versions") + .and_then(|v| v.get("stable")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let head = value + .get("versions") + .and_then(|v| v.get("head")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let caveats = value + .get("caveats") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let dependencies = value + .get("dependencies") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + let tap = value + .get("tap") + .and_then(|v| v.as_str()) + .unwrap_or("homebrew/core") + .to_string(); + let installed = value + .get("installed") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|i| { + let version = i.get("version").and_then(|v| v.as_str())?.to_string(); + let installed_as_dependency = i + .get("installed_as_dependency") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let installed_on_request = i + .get("installed_on_request") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Some(InstalledVersion { + version, + installed_as_dependency, + installed_on_request, + }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(BrewInfo { + name, + description, + homepage, + versions: Versions { stable, head }, + installed, + caveats, + dependencies, + tap, + is_cask: false, + }) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn parse_cask_info(value: &serde_json::Value) -> Result { + let name = value + .get("token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let description = value.get("desc").and_then(|v| v.as_str()).map(|s| s.to_string()); + let homepage = value + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let stable = value + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let caveats = value + .get("caveats") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let tap = value + .get("tap") + .and_then(|v| v.as_str()) + .unwrap_or("homebrew/cask") + .to_string(); + let dependencies = value + .get("depends_on") + .and_then(|v| v.as_object()) + .map(|obj| { + let mut deps = Vec::new(); + if let Some(formula) = obj.get("formula").and_then(|v| v.as_array()) { + deps.extend( + formula + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())), + ); + } + if let Some(cask) = obj.get("cask").and_then(|v| v.as_array()) { + deps.extend( + cask.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())), + ); + } + deps + }) + .unwrap_or_default(); + let installed = if let Some(installed_version) = value.get("installed").and_then(|v| v.as_str()) { + vec![InstalledVersion { + version: installed_version.to_string(), + installed_as_dependency: false, + installed_on_request: true, + }] + } else { + vec![] + }; + Ok(BrewInfo { + name, + description, + homepage, + versions: Versions { + stable, + head: None, + }, + installed, + caveats, + dependencies, + tap, + is_cask: true, + }) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn run_brew_operation_in_background( + app: tauri::AppHandle, + state: tauri::State<'_, BrewOperationState>, + args: Vec, + command_label: String, +) -> Result<(), String> { + let manager = &state.manager; + if !brew_cli_installed() { + return Err(brew_err( + "CLI_MISSING", + "Homebrew is not installed or not on PATH.".into(), + )); + } + let op_id = manager.start(command_label)?; + + let manager = manager.clone(); + let app = app.clone(); + + std::thread::spawn(move || { + let mut command = Command::new("brew"); + command.args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + let child = match command.spawn() + { + Ok(child) => child, + Err(e) => { + let seq = manager.next_seq(); + let _ = app.emit( + "brew-operation-output", + BrewOperationEvent { + chunk: format!("Failed to start brew: {}\n", e), + seq, + done: true, + success: false, + }, + ); + manager.finish(op_id, false); + return; + } + }; + + { + if let Ok(mut c) = manager.child.lock() { + *c = Some(child); + } + } + + let stdout = { + let mut c = manager.child.lock().unwrap(); + c.as_mut().and_then(|c| c.stdout.take()) + }; + let stderr = { + let mut c = manager.child.lock().unwrap(); + c.as_mut().and_then(|c| c.stderr.take()) + }; + + let manager_stdout = manager.clone(); + let app_stdout = app.clone(); + let stdout_handle = stdout.map(|pipe| { + std::thread::spawn(move || { + use std::io::{BufRead, BufReader}; + let reader = BufReader::new(pipe); + for line in reader.lines() { + if let Ok(line) = line { + let chunk = format!("{}\n", line); + manager_stdout.append_output(op_id, &chunk); + let seq = manager_stdout.next_seq(); + let _ = app_stdout.emit( + "brew-operation-output", + BrewOperationEvent { + chunk, + seq, + done: false, + success: false, + }, + ); + } + } + }) + }); + + let manager_stderr = manager.clone(); + let _app_stderr = app.clone(); + let stderr_handle = stderr.map(|pipe| { + std::thread::spawn(move || { + use std::io::{BufRead, BufReader}; + let reader = BufReader::new(pipe); + for line in reader.lines() { + if let Ok(line) = line { + let chunk = format!("{}\n", line); + manager_stderr.append_output(op_id, &chunk); + // Do not emit stderr to avoid duplicate lines; + // Homebrew often writes the same output to both stdout and stderr. + } + } + }) + }); + + let success = loop { + std::thread::sleep(Duration::from_millis(100)); + let mut guard = manager.child.lock().unwrap(); + if let Some(ref mut child) = *guard { + match child.try_wait() { + Ok(Some(status)) => { + let success = status.success(); + drop(guard); + break success; + } + Ok(None) => continue, + Err(_) => { + drop(guard); + break false; + } + } + } else { + drop(guard); + break false; + } + }; + + if let Some(h) = stdout_handle { + let _ = h.join(); + } + if let Some(h) = stderr_handle { + let _ = h.join(); + } + + let seq = manager.next_seq(); + let _ = app.emit( + "brew-operation-output", + BrewOperationEvent { + chunk: if success { + "\nDone.\n".to_string() + } else { + "\nFailed.\n".to_string() + }, + seq, + done: true, + success, + }, + ); + + manager.finish(op_id, success); + }); + + Ok(()) +} + +#[tauri::command] +async fn brew_install( + app: tauri::AppHandle, + state: tauri::State<'_, BrewOperationState>, + name: String, + cask: bool, +) -> Result<(), String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let mut args = vec!["install".into()]; + if cask { + args.push("--cask".into()); + } + args.push("--".into()); + args.push(name.clone()); + run_brew_operation_in_background(app, state, args, format!("Install {}", name)) + } +} + +#[tauri::command] +async fn brew_uninstall( + app: tauri::AppHandle, + state: tauri::State<'_, BrewOperationState>, + name: String, + cask: bool, + confirmed: Option, +) -> Result<(), String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + require_confirmed(confirmed, "Uninstalling Homebrew packages")?; + let mut args = vec!["uninstall".into()]; + if cask { + args.push("--cask".into()); + } + args.push("--".into()); + args.push(name.clone()); + run_brew_operation_in_background(app, state, args, format!("Uninstall {}", name)) + } +} + +#[tauri::command] +async fn brew_upgrade( + app: tauri::AppHandle, + state: tauri::State<'_, BrewOperationState>, + name: Option, + cask: bool, + confirmed: Option, +) -> Result<(), String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if name.is_none() { + require_confirmed(confirmed, "Upgrading all Homebrew packages")?; + } + let mut args = vec!["upgrade".into()]; + if cask { + args.push("--cask".into()); + } + if let Some(n) = name.clone() { + args.push("--".into()); + args.push(n); + } + let label = name.map(|n| format!("Upgrade {}", n)).unwrap_or_else(|| "Upgrade all".into()); + run_brew_operation_in_background(app, state, args, label) + } +} + +#[tauri::command] +async fn brew_outdated() -> Result, String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + run_blocking(brew_outdated_blocking).await? + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn brew_outdated_blocking() -> Result, String> { + let args = vec!["outdated".into(), "--json=v2".into()]; + let result = brew_command_output(&args, Duration::from_secs(60))?; + if !result.success { + return Err(brew_err("COMMAND_FAILED", result.stderr)); + } + let json: serde_json::Value = serde_json::from_str(&result.stdout) + .map_err(|e| brew_err("PARSE_FAILED", e.to_string()))?; + let mut packages = Vec::new(); + if let Some(formulae) = json.get("formulae").and_then(|v| v.as_array()) { + for f in formulae { + let name = f.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let installed = f + .get("installed_versions") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let current = f + .get("current_version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !name.is_empty() { + packages.push(BrewOutdatedPackage { + name, + installed_version: installed, + current_version: current, + is_cask: false, + }); + } + } + } + if let Some(casks) = json.get("casks").and_then(|v| v.as_array()) { + for c in casks { + let name = c + .get("token") + .or_else(|| c.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let installed = c + .get("installed_versions") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let current = c + .get("current_version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !name.is_empty() { + packages.push(BrewOutdatedPackage { + name, + installed_version: installed, + current_version: current, + is_cask: true, + }); + } + } + } + Ok(packages) +} + +#[tauri::command] +async fn brew_update( + app: tauri::AppHandle, + state: tauri::State<'_, BrewOperationState>, +) -> Result<(), String> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("Homebrew is not available on this platform.".into()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let args = vec!["update".into()]; + run_brew_operation_in_background(app, state, args, "Update Homebrew".into()) + } +} + +#[tauri::command] +fn brew_operation_status( + state: tauri::State<'_, BrewOperationState>, +) -> Result { + state.manager.status() +} + +#[tauri::command] +fn brew_operation_cancel( + state: tauri::State<'_, BrewOperationState>, +) -> Result<(), String> { + state.manager.cancel() +} + #[cfg(target_os = "macos")] fn get_macos_app_icon_path(app_path: &std::path::Path) -> Option { let plist_path = app_path.join("Contents/Info.plist"); @@ -4098,7 +5112,9 @@ fn list_apps(app: tauri::AppHandle, cache_state: tauri::State) - let mut paths = vec![ "/Applications".to_string(), + "/Applications/Utilities".to_string(), "/System/Applications".to_string(), + "/System/Applications/Utilities".to_string(), ]; if let Ok(home) = std::env::var("HOME") { paths.push(format!("{}/Applications", home)); @@ -4107,18 +5123,26 @@ fn list_apps(app: tauri::AppHandle, cache_state: tauri::State) - let mut app_entries: Vec<(std::path::PathBuf, String)> = Vec::new(); for path in paths { - if let Ok(entries) = std::fs::read_dir(&path) { - for entry in entries.flatten() { - let path = entry.path(); - if path - .extension() - .map_or(false, |ext| ext.eq_ignore_ascii_case("app")) - { - let name = path - .file_stem() - .map_or("Unknown".to_string(), |s| s.to_string_lossy().to_string()); - app_entries.push((path, name)); + match std::fs::read_dir(&path) { + Ok(entries) => { + let mut count = 0; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .map_or(false, |ext| ext.eq_ignore_ascii_case("app")) + { + let name = path + .file_stem() + .map_or("Unknown".to_string(), |s| s.to_string_lossy().to_string()); + app_entries.push((path, name)); + count += 1; + } } + println!("list_apps: found {} apps in {}", count, path); + } + Err(e) => { + eprintln!("list_apps: failed to read directory {}: {}", path, e); } } } @@ -4366,10 +5390,13 @@ fn capture_region( cropped.height() )); - let desktop_dir = dirs::desktop_dir().unwrap_or_else(|| { - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) - }); - let path = desktop_dir + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + std::fs::create_dir_all(&app_data_dir) + .map_err(|e| format!("Failed to create app data dir: {}", e))?; + let path = app_data_dir .join("gquick_capture.png") .to_string_lossy() .to_string(); @@ -5243,6 +6270,10 @@ pub fn run() { inline_processes: Arc::new(Mutex::new(HashMap::new())), }); + app.manage(BrewOperationState { + manager: BrewOperationManager::new(), + }); + app.manage(AppsCacheState { apps: Mutex::new(Vec::new()), last_updated: Mutex::new(Instant::now() - Duration::from_secs(60)), @@ -5363,7 +6394,18 @@ pub fn run() { run_terminal_command_inline, cancel_terminal_command, cancel_all_terminal_commands, - hide_main_window + hide_main_window, + brew_status, + brew_list, + brew_search, + brew_info, + brew_install, + brew_uninstall, + brew_upgrade, + brew_outdated, + brew_update, + brew_operation_status, + brew_operation_cancel ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 219f69b..3b66673 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GQuick", - "version": "0.1.8", + "version": "0.1.9", "identifier": "com.gquick.dev", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index cc1c73c..59c0d07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; -import { Search, Command, Settings as SettingsIcon, MessageSquare, ChevronRight, ChevronDown, Send, User, Bot, Loader2, Zap, ImagePlus, X, RotateCcw, StickyNote, Box, Terminal, Plus, RefreshCw } from "lucide-react"; +import { Search, Command, Settings as SettingsIcon, MessageSquare, ChevronRight, ChevronDown, Send, User, Bot, Loader2, Zap, ImagePlus, X, RotateCcw, StickyNote, Box, Terminal, Plus, RefreshCw, Beer } from "lucide-react"; import { cn } from "./utils/cn"; import { getPluginsForQuery, plugins } from "./plugins"; import { SearchResultItem } from "./plugins/types"; @@ -11,6 +11,7 @@ import { MarkdownMessage } from "./components/MarkdownMessage"; import { Tooltip } from "./components/Tooltip"; import { NotesView } from "./components/NotesView"; import { DockerView, type DockerInitialImage } from "./components/DockerView"; +import { HomebrewView } from "./components/HomebrewView"; import SearchSuggestions from "./components/SearchSuggestions"; import { isQuickTranslateQuery, performQuickTranslate } from "./utils/quickTranslate"; import { streamOpenAITools, streamOpenAIResponsesTools, streamGeminiTools, streamAnthropicTools } from "./utils/streaming"; @@ -18,6 +19,7 @@ import { getAllTools, executeTool, convertToolsForProvider, convertToolsForOpenA import { ToolCall } from "./plugins/types"; import { getSavedLocation } from "./utils/location"; import { recordUsage, getRecentItems } from "./utils/usageTracker"; +import { isSpeedtestRunning, cancelSpeedtest } from "./plugins/speedtest"; import UpdateModal from "./components/UpdateModal"; const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; @@ -30,6 +32,7 @@ const WINDOW_RESIZE_DEBOUNCE_MS = 80; /** Views that open in the expanded (larger) window. Add new views here. */ const EXPANDED_WINDOW_VIEWS: Record = { docker: { width: 1200, height: 860 }, + homebrew: { width: 1200, height: 860 }, chat: { width: 1200, height: 860 }, }; @@ -55,6 +58,11 @@ function isDockerShortcut(e: KeyboardEvent, isLeftShiftPressed: boolean): boolea return (e.metaKey || e.ctrlKey) && e.shiftKey && isLeftShiftPressed && isDKey; } +function isHomebrewShortcut(e: KeyboardEvent, isLeftShiftPressed: boolean): boolean { + const isBKey = e.code === "KeyB" || e.key.toLowerCase() === "b"; + return (e.metaKey || e.ctrlKey) && e.shiftKey && isLeftShiftPressed && isBKey; +} + function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean { const parts = shortcut.split("+"); const keyPart = parts[parts.length - 1]; @@ -235,7 +243,7 @@ function isLikelyInteractiveCommand(command: string): boolean { function App() { const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); - const [view, setView] = useState<"search" | "chat" | "settings" | "actions" | "notes" | "docker">("search"); + const [view, setView] = useState<"search" | "chat" | "settings" | "actions" | "notes" | "docker" | "homebrew">("search"); const [activeActionIndex, setActiveActionIndex] = useState(0); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); const [items, setItems] = useState([]); @@ -284,9 +292,11 @@ function App() { const [attachedImages, setAttachedImages] = useState([]); const [notesContext, setNotesContext] = useState<{ title: string; content: string }[] | null>(null); const [dockerInitialImage, setDockerInitialImage] = useState(null); + const [homebrewInitialPackage, setHomebrewInitialPackage] = useState<{ name: string; isCask: boolean } | null>(null); const [initialNoteId, setInitialNoteId] = useState(null); const [notesSearchQuery, setNotesSearchQuery] = useState(""); const [dockerSearchQuery, setDockerSearchQuery] = useState(""); + const [homebrewSearchQuery, setHomebrewSearchQuery] = useState(""); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); const [autoCheckUpdateInfo, setAutoCheckUpdateInfo] = useState<{ version: string; body: string | null } | null>(null); const [autoCheckUpdateObj, setAutoCheckUpdateObj] = useState(null); @@ -468,6 +478,19 @@ function App() { return () => window.removeEventListener("gquick-open-docker", handleOpenDocker); }, []); + useEffect(() => { + const handleOpenHomebrew = (event: Event) => { + const detail = (event as CustomEvent<{ pkg?: { name: string; isCask: boolean }; searchTerm?: string } | undefined>).detail; + setHomebrewInitialPackage(detail?.pkg ?? null); + if (detail?.searchTerm !== undefined) { + setHomebrewSearchQuery(detail.searchTerm); + } + setView("homebrew"); + }; + window.addEventListener("gquick-open-homebrew", handleOpenHomebrew); + return () => window.removeEventListener("gquick-open-homebrew", handleOpenHomebrew); + }, []); + useEffect(() => { const handleFocus = () => { inputRef.current?.focus(); @@ -598,6 +621,7 @@ function App() { setInitialNoteId(null); setNotesSearchQuery(""); setDockerSearchQuery(""); + setHomebrewSearchQuery(""); }); if (disposed) cleanup(); else unlisten = cleanup; @@ -912,6 +936,15 @@ function App() { setDockerSearchQuery(""); } + if (isHomebrewShortcut(e, isLeftShiftPressedRef.current) && view !== "actions") { + e.preventDefault(); + setView(prev => prev === "homebrew" ? "search" : "homebrew"); + setQuery(""); + setChatInput(""); + setNotesContext(null); + setHomebrewSearchQuery(""); + } + if ((e.metaKey || e.ctrlKey) && e.key === "n" && view !== "actions") { e.preventDefault(); setInitialNoteId(null); @@ -956,6 +989,14 @@ function App() { } if (e.key === "Escape") { + if (inlineCommandRef.current?.status === "running") { + void cancelInlineCommand(); + return; + } + if (isSpeedtestRunning()) { + cancelSpeedtest(); + return; + } if (view === "actions") { setView("search"); } else if (view !== "search") { @@ -965,6 +1006,9 @@ function App() { setView("search"); setQuery(""); } + } else if (query !== "") { + setQuery(""); + return; } else { void hideWindowSafely(); } @@ -987,7 +1031,7 @@ function App() { window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", resetLeftShift); }; - }, [view, attachedImages, hideWindowSafely]); + }, [view, attachedImages, hideWindowSafely, cancelInlineCommand, query]); // Fetch items from plugins useEffect(() => { @@ -1235,7 +1279,7 @@ function App() { inputRef.current?.focus(); }, []); - const handleOpenView = useCallback((v: "chat" | "notes" | "docker" | "settings" | "actions") => { + const handleOpenView = useCallback((v: "chat" | "notes" | "docker" | "homebrew" | "settings" | "actions") => { if (v === "notes") setInitialNoteId(null); if (v === "docker") setDockerInitialImage(null); setView(v); @@ -1264,16 +1308,18 @@ function App() { const src = prefix.source; const altMatch = src.match(/\(([^)]+)\)/); if (altMatch) { - return altMatch[1].split("|").map(s => s.replace(/^\\\//, "/").replace(/\\/g, "")).join(", "); + // Return only the first alternative + return altMatch[1].split("|")[0].replace(/^\\\//, "/").replace(/\\/g, ""); } // Strip anchors/flags for simple patterns - return src.replace(/^\^/, "").replace(/\$$/, "").replace(/\\\//g, "/"); + return src.replace(/^\^/, "").replace(/\$/, "").replace(/\\\//g, "/"); }; const appActions = [ { id: "chat", label: "Open Chat", icon: MessageSquare, shortcut: `${modKey} L⇧ C`, onClick: () => setView("chat") }, { id: "notes", label: "Notes", icon: StickyNote, shortcut: `${modKey} N`, onClick: () => { setInitialNoteId(null); setView("notes"); } }, { id: "docker", label: "Docker", icon: Box, shortcut: `${modKey} L⇧ D`, onClick: () => { setDockerInitialImage(null); setView("docker"); } }, + { id: "homebrew", label: "Homebrew", icon: Beer, shortcut: `${modKey} L⇧ B`, onClick: () => { setView("homebrew"); } }, { id: "search-notes", label: "Search Notes", icon: Search, shortcut: `${modKey} ⇧ S`, onClick: () => { setView("search"); setQuery("search notes: "); inputRef.current?.focus(); } }, { id: "settings", label: "Settings", icon: SettingsIcon, shortcut: `${modKey},`, onClick: () => setView("settings") }, ]; @@ -1295,6 +1341,7 @@ function App() { { id: "chat", view: "chat" as const }, { id: "notes", view: "notes" as const }, { id: "docker", view: "docker" as const }, + { id: "homebrew", view: "homebrew" as const }, { id: "settings", view: "settings" as const }, { id: "actions", view: "actions" as const }, ]; @@ -1659,6 +1706,8 @@ function App() { ) : view === "docker" ? ( + ) : view === "homebrew" ? ( + ) : ( )} @@ -1689,18 +1738,18 @@ function App() { { if (view === "chat") setChatInput(e.target.value); else if (view === "notes") setNotesSearchQuery(e.target.value); else if (view === "docker") setDockerSearchQuery(e.target.value); + else if (view === "homebrew") setHomebrewSearchQuery(e.target.value); else { const nextQuery = e.target.value; if (nextQuery === query) return; latestSearchQueryRef.current = nextQuery; searchRequestIdRef.current += 1; itemsRef.current = []; - setItems([]); setQuery(nextQuery); setActiveIndex(0); } @@ -1711,7 +1760,7 @@ function App() { }} disabled={view === "settings"} readOnly={view === "actions"} - placeholder={view === "settings" ? "Settings" : view === "actions" ? "Actions" : view === "notes" ? "Search notes..." : view === "docker" ? "Search Docker Hub, images, containers..." : view === "search" ? "Search for apps, files, or ask anything..." : "Ask GQuick anything..."} + placeholder={view === "settings" ? "Settings" : view === "actions" ? "Actions" : view === "notes" ? "Search notes..." : view === "docker" ? "Search Docker Hub, images, containers..." : view === "homebrew" ? "Search Homebrew packages..." : view === "search" ? "Search for apps, files, or ask anything..." : "Ask GQuick anything..."} className="min-w-0 flex-1 bg-transparent text-lg text-zinc-100 placeholder-zinc-500 outline-none disabled:opacity-50 read-only:opacity-50" spellCheck={false} /> @@ -1781,6 +1830,23 @@ function App() { K + ) : view === "homebrew" ? ( +
+ +
setView("actions")} + > + + K +
+
) : (
- + {action.label}
{action.shortcut} @@ -1879,8 +1945,8 @@ function App() {
{plugin.queryPrefixes && plugin.queryPrefixes.length > 0 - ? plugin.queryPrefixes.map(formatQueryPrefix).join(", ") - : plugin.keywords.slice(0, 2).join(", ")} + ? formatQueryPrefix(plugin.queryPrefixes[0]) + : plugin.keywords[0] || ""} ); @@ -1893,6 +1959,8 @@ function App() { ) : view === "docker" ? ( + ) : view === "homebrew" ? ( + ) : view === "chat" ? (
@@ -2093,15 +2161,11 @@ function App() { ); })}
- ) : isSearching || isTranslating ? ( -
-

Waiting for results…

-
- ) : ( + ) : !isSearching && !isTranslating ? (

No results found for "{query}"

- )} + ) : null} {isSearching && searchStatus && (
diff --git a/src/components/HomebrewView.tsx b/src/components/HomebrewView.tsx new file mode 100644 index 0000000..0b4c205 --- /dev/null +++ b/src/components/HomebrewView.tsx @@ -0,0 +1,1126 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { KeyboardEvent, PointerEvent as ReactPointerEvent } from "react" +import type { LucideIcon } from "lucide-react" +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import { openUrl } from "@tauri-apps/plugin-opener" +import { Loader2, Trash2, ArrowUpCircle, Download, X, ExternalLink, Info, Package, Monitor, Search, CheckCircle2, AlertTriangle } from "lucide-react" +import { cn } from "../utils/cn" +import { BREW_SEARCH_DEBOUNCE_MS } from "../plugins/homebrew" + +type Tab = "installed" | "search" | "outdated" + +type ConfirmDialogType = + | { type: "uninstall"; name: string; cask: boolean } + | { type: "upgrade"; name: string; cask: boolean } + | { type: "upgrade-all"; count: number } + | null + +interface BrewStatus { + installed: boolean + version?: string + prefix?: string + error?: string +} + +interface BrewPackage { + name: string + version: string + tap: string + desc?: string + homepage?: string + is_cask: boolean + outdated: boolean + latest_version?: string +} + +interface BrewSearchResult { + name: string + description?: string + version?: string + tap: string + is_cask: boolean +} + +interface BrewInfo { + name: string + description?: string + homepage?: string + versions: { stable?: string; head?: string } + installed: { version: string; installed_as_dependency: boolean; installed_on_request: boolean }[] + caveats?: string + dependencies: string[] + tap: string + is_cask: boolean +} + +interface BrewOutdatedPackage { + name: string + installed_version: string + current_version: string + is_cask: boolean +} + +interface BrewOperationStatus { + running: boolean + command?: string + output: string +} + +interface HomebrewViewProps { + searchQuery: string + onSearchQueryChange?: (q: string) => void + initialPackage?: { name: string; isCask: boolean } +} + +const detailPanelWidthKey = "homebrew-detail-panel-width" +const defaultDetailPanelWidth = 320 +const minDetailPanelWidth = 240 +const maxDetailPanelWidth = 640 + +function clampDetailPanelWidth(width: number, viewportWidth = window.innerWidth): number { + return Math.round( + Math.min( + Math.max(width, minDetailPanelWidth), + Math.min(maxDetailPanelWidth, Math.max(minDetailPanelWidth, viewportWidth - 180)) + ) + ) +} + +function initialDetailPanelWidth(): number { + const savedWidth = Number(localStorage.getItem(detailPanelWidthKey)) + return clampDetailPanelWidth( + Number.isFinite(savedWidth) && savedWidth > 0 ? savedWidth : defaultDetailPanelWidth + ) +} + +function shortError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function formatTimeAgo(date: Date): string { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSec = Math.floor(diffMs / 1000) + if (diffSec < 10) return "just now" + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin} min ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + const diffDay = Math.floor(diffHr / 24) + return `${diffDay}d ago` +} + +function EmptyState({ icon: Icon, title, subtitle, action }: { icon: LucideIcon; title: string; subtitle?: string; action?: { label: string; onClick: () => void } }) { + return ( +
+ +
{title}
+ {subtitle &&
{subtitle}
} + {action && ( + + )} +
+ ) +} + +function SkeletonRows({ count = 5 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) +} + +export function HomebrewView({ searchQuery, onSearchQueryChange, initialPackage }: HomebrewViewProps) { + const [tab, setTab] = useState("installed") + const [status, setStatus] = useState(null) + const [installed, setInstalled] = useState([]) + const [searchResults, setSearchResults] = useState([]) + const [outdated, setOutdated] = useState([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [selected, setSelected] = useState(null) + const [detailPanelWidth, setDetailPanelWidth] = useState(initialDetailPanelWidth) + const [error, setError] = useState(null) + const [output, setOutput] = useState("Ready") + const [isPanelLoading, setIsPanelLoading] = useState(false) + const [lastRefreshed, setLastRefreshed] = useState(null) + const [confirmDialog, setConfirmDialog] = useState(null) + const [pendingAction, setPendingAction] = useState(null) + const [searching, setSearching] = useState(false) + const [showLogPopup, setShowLogPopup] = useState(false) + const refreshSeq = useRef(0) + const mountedRef = useRef(false) + const searchSeq = useRef(0) + const selectedRef = useRef(null) + const selectSeq = useRef(0) + + useEffect(() => { + selectedRef.current = selected + }, [selected]) + + const refresh = useCallback(async () => { + const seq = ++refreshSeq.current + setLoading(true) + setError(null) + try { + const currentStatus = await invoke("brew_status") + if (!mountedRef.current || seq !== refreshSeq.current) return + setStatus(currentStatus) + if (currentStatus.installed) { + const nextInstalled = await invoke("brew_list") + if (!mountedRef.current || seq !== refreshSeq.current) return + setInstalled(nextInstalled) + const nextOutdated = await invoke("brew_outdated") + if (!mountedRef.current || seq !== refreshSeq.current) return + setOutdated(nextOutdated) + } + if (mountedRef.current && seq === refreshSeq.current) { + setLastRefreshed(new Date()) + } + } catch (err) { + if (mountedRef.current && seq === refreshSeq.current) { + setError(shortError(err)) + } + } finally { + if (mountedRef.current && seq === refreshSeq.current) setLoading(false) + } + }, []) + + useEffect(() => { + mountedRef.current = true + + const checkExisting = async () => { + try { + const status = await invoke("brew_operation_status") + if (status.running && mountedRef.current) { + setBusy(true) + setOutput(status.output || "Operation in progress...") + } + } catch { + // Ignore + } + } + checkExisting() + + let timeoutId: number | undefined + const rafId = window.requestAnimationFrame(() => { + timeoutId = window.setTimeout(() => void refresh(), 0) + }) + return () => { + mountedRef.current = false + refreshSeq.current += 1 + window.cancelAnimationFrame(rafId) + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + } + }, [refresh]) + + useEffect(() => { + localStorage.setItem(detailPanelWidthKey, String(detailPanelWidth)) + }, [detailPanelWidth]) + + useEffect(() => { + let cancelled = false + let unlisten: (() => void) | undefined + const seenSeq = new Set() + + const setup = async () => { + const cleanup = await listen<{ chunk: string; seq: number; done: boolean; success: boolean }>( + "brew-operation-output", + (event) => { + if (!mountedRef.current) return + const { chunk, seq, done, success } = event.payload + if (seenSeq.has(seq)) return + seenSeq.add(seq) + setOutput((prev) => prev + chunk) + if (done) { + setBusy(false) + setPendingAction(null) + if (success) { + refresh() + if (selectedRef.current && selectedRef.current.name) { + invoke("brew_info", { name: selectedRef.current.name }) + .then((info) => { + if (mountedRef.current) { + setSelected(info) + } + }) + .catch((err) => { + if (mountedRef.current) { + setOutput(shortError(err)) + } + }) + } + } + } + } + ) + if (!cancelled) { + unlisten = cleanup + } else { + cleanup() + } + } + + setup() + + return () => { + cancelled = true + if (unlisten) unlisten() + } + }, [refresh]) + + useEffect(() => { + const handleRefresh = () => void refresh() + window.addEventListener("gquick-homebrew-refresh", handleRefresh) + return () => window.removeEventListener("gquick-homebrew-refresh", handleRefresh) + }, [refresh]) + + useEffect(() => { + const clampToViewport = () => setDetailPanelWidth((width) => clampDetailPanelWidth(width)) + window.addEventListener("resize", clampToViewport) + return () => window.removeEventListener("resize", clampToViewport) + }, []) + + useEffect(() => { + if (tab !== "search" || searchQuery.trim().length < 2) { + setSearchResults([]) + setSearching(false) + return + } + const seq = ++searchSeq.current + setSearching(true) + const timer = window.setTimeout(async () => { + try { + const results = await invoke("brew_search", { query: searchQuery }) + if (seq === searchSeq.current) { + setSearchResults(results) + setSearching(false) + } + } catch (err) { + if (seq !== searchSeq.current) return + setOutput(shortError(err)) + setSearching(false) + } + }, BREW_SEARCH_DEBOUNCE_MS) + return () => { + window.clearTimeout(timer) + } + }, [searchQuery, tab]) + + async function runAction(action: () => Promise, _label: string, actionId?: string) { + if (busy || pendingAction) return + if (actionId) setPendingAction(actionId) + setBusy(true) + setOutput("") + try { + await action() + // Output and completion come from brew-operation-output events + } catch (err) { + if (mountedRef.current) { + setBusy(false) + setOutput(shortError(err)) + if (actionId) setPendingAction(null) + } + } + } + + const filteredInstalled = useMemo( + () => + installed.filter((p) => + `${p.name} ${p.tap} ${p.desc || ""}`.toLowerCase().includes(searchQuery.toLowerCase()) + ), + [installed, searchQuery] + ) + + const filteredOutdated = useMemo( + () => + outdated.filter((p) => + `${p.name} ${p.installed_version} ${p.current_version}`.toLowerCase().includes(searchQuery.toLowerCase()) + ), + [outdated, searchQuery] + ) + + const selectPackage = useCallback(async (name: string, isCask: boolean) => { + if (!name) { + console.warn("selectPackage called without name", { name, isCask }) + return + } + const seq = ++selectSeq.current + // Open panel instantly with minimal data + setSelected({ + name, + is_cask: isCask, + tap: isCask ? "homebrew/cask" : "homebrew/core", + versions: {}, + installed: [], + dependencies: [], + description: undefined, + homepage: undefined, + caveats: undefined, + }) + setIsPanelLoading(true) + try { + const info = await invoke("brew_info", { name }) + if (mountedRef.current && selectSeq.current === seq) { + setSelected(info) + setIsPanelLoading(false) + } + } catch (err) { + if (mountedRef.current && selectSeq.current === seq) { + setOutput(shortError(err)) + setIsPanelLoading(false) + } + } + }, []) + + useEffect(() => { + if (initialPackage) { + void selectPackage(initialPackage.name, initialPackage.isCask) + } + }, [initialPackage, selectPackage]) + + const startDetailPanelResize = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const startX = event.clientX + const startWidth = detailPanelWidth + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + const onPointerMove = (moveEvent: PointerEvent) => { + setDetailPanelWidth(clampDetailPanelWidth(startWidth + startX - moveEvent.clientX)) + } + const stopResize = () => { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + window.removeEventListener("pointermove", onPointerMove) + window.removeEventListener("pointerup", stopResize) + window.removeEventListener("pointercancel", stopResize) + } + window.addEventListener("pointermove", onPointerMove) + window.addEventListener("pointerup", stopResize) + window.addEventListener("pointercancel", stopResize) + }, + [detailPanelWidth] + ) + + const statusLabel = status?.installed + ? status.version || "Installed" + : status?.error || "Not installed" + const statusClass = status?.installed + ? "text-amber-300 border-amber-500/30 bg-amber-500/10" + : "text-red-300 border-red-500/30 bg-red-500/10" + + return ( +
+
+
+ {(["installed", "search", "outdated"] as Tab[]).map((item) => ( + + ))} +
+ +
+
+ + ● {loading ? "Loading" : statusLabel} + + {status?.prefix && ( + + {status.prefix} + + )} +
+ {status?.installed && ( + + )} +
+
+ + {loading ? ( + + ) : error ? ( +
{error}
+ ) : !status?.installed ? ( +
+ {status?.error || "Homebrew is not installed or not on PATH."} +
+ ) : tab === "installed" ? ( + filteredInstalled.length === 0 ? ( + { setTab("search"); onSearchQueryChange("") } } : undefined} + /> + ) : ( + ({ + id: `${p.is_cask ? "cask" : "formula"}-${p.name}`, + icon: p.is_cask ? "cask" as const : "formula" as const, + title: p.name, + subtitle: `${p.is_cask ? "Cask" : "Formula"} • ${p.version}${p.outdated ? ` • ${p.latest_version || "update available"}` : ""}`, + badge: p.is_cask ? "Cask" : "Formula", + onClick: () => selectPackage(p.name, p.is_cask), + actions: [ + ...(p.outdated + ? [ + { + id: "upgrade", + label: "Upgrade", + onRun: () => { + setConfirmDialog({ type: "upgrade", name: p.name, cask: p.is_cask }) + }, + }, + ] + : []), + { + id: "uninstall", + label: "Uninstall", + destructive: true, + onRun: () => setConfirmDialog({ type: "uninstall", name: p.name, cask: p.is_cask }), + }, + { + id: "info", + label: "Info", + onRun: () => selectPackage(p.name, p.is_cask), + }, + ], + }))} + /> + ) + ) : tab === "search" ? ( + searchQuery.trim().length < 2 ? ( + + ) : searching ? ( +
+ +
Searching Homebrew…
+
+ ) : searchResults.length === 0 ? ( + + ) : ( + ({ + id: `${r.is_cask ? "cask" : "formula"}-${r.name}`, + icon: r.is_cask ? "cask" as const : "formula" as const, + title: r.name, + subtitle: `${r.is_cask ? "Cask" : "Formula"}${r.description ? ` • ${r.description}` : ""}${r.version ? ` • ${r.version}` : ""}`, + badge: r.is_cask ? "Cask" : "Formula", + onClick: () => selectPackage(r.name, r.is_cask), + actions: [ + { + id: "install", + label: "Install", + onRun: () => { + void runAction(() => invoke("brew_install", { name: r.name, cask: r.is_cask }), `Install ${r.name}`, `install-${r.name}`) + }, + }, + { + id: "info", + label: "Info", + onRun: () => selectPackage(r.name, r.is_cask), + }, + ], + }))} + /> + ) + ) : ( +
+ {filteredOutdated.length > 0 && ( +
+ {filteredOutdated.length} outdated package(s) + +
+ )} + {filteredOutdated.length === 0 ? ( + + ) : ( + ({ + id: `${p.is_cask ? "cask" : "formula"}-${p.name}`, + icon: p.is_cask ? "cask" as const : "formula" as const, + title: p.name, + subtitle: `${p.is_cask ? "Cask" : "Formula"} • ${p.installed_version} → ${p.current_version}`, + badge: p.is_cask ? "Cask" : "Formula", + onClick: () => selectPackage(p.name, p.is_cask), + actions: [ + { + id: "upgrade", + label: "Upgrade", + onRun: () => { + setConfirmDialog({ type: "upgrade", name: p.name, cask: p.is_cask }) + }, + }, + { + id: "uninstall", + label: "Uninstall", + destructive: true, + onRun: () => setConfirmDialog({ type: "uninstall", name: p.name, cask: p.is_cask }), + }, + { + id: "info", + label: "Info", + onRun: () => selectPackage(p.name, p.is_cask), + }, + ], + }))} + emptyText="No outdated packages." + /> + )} +
+ )} +
+ + setSelected(null)} + onResizeStart={startDetailPanelResize} + busy={busy} + output={output} + loading={isPanelLoading} + pendingAction={pendingAction} + onInstall={(name, cask) => { + void runAction(() => invoke("brew_install", { name, cask }), `Install ${name}`, `install-${name}`) + }} + onUninstall={(name, cask) => { + setConfirmDialog({ type: "uninstall", name, cask }) + }} + onUpgrade={(name, cask) => { + setConfirmDialog({ type: "upgrade", name, cask }) + }} + /> +
+ +
+ {status?.version || "Homebrew"} + + {installed.length} installed + + 0 ? "text-amber-400" : ""}>{outdated.length} outdated + + + {busy && ( + + )} + {lastRefreshed && ( + <> + + Last refreshed: {formatTimeAgo(lastRefreshed)} + + )} +
+ + {showLogPopup && ( +
setShowLogPopup(false)} + > +
e.stopPropagation()} + > +
+ Command Output + +
+
+              {output}
+            
+
+
+ )} + + {confirmDialog?.type === "uninstall" && ( +
+
+
+ + + +

Uninstall {confirmDialog.name}?

+
+

+ This will remove {confirmDialog.name} and all associated files. This action cannot be undone. +

+
+ + +
+
+
+ )} + + {confirmDialog?.type === "upgrade-all" && ( +
+
+
+ + + +

Upgrade {confirmDialog.count} packages?

+
+

+ This will upgrade all outdated formulae and casks. +

+
+ + +
+
+
+ )} + + {confirmDialog?.type === "upgrade" && ( +
+
+
+ + + +

Upgrade {confirmDialog.name}?

+
+

+ This will upgrade {confirmDialog.name} to the latest version. +

+
+ + +
+
+
+ )} +
+ ) +} + +function PackageRows({ + items, + pendingAction, + emptyText = "No packages.", +}: { + items: { + id: string + icon?: "formula" | "cask" + title: string + subtitle: string + badge?: string + onClick: () => void + actions: { id: string; label: string; destructive?: boolean; onRun: () => void }[] + }[] + pendingAction?: string | null + emptyText?: string +}) { + if (items.length === 0) { + return ( +
+ {emptyText} +
+ ) + } + return ( +
+ {items.map((item) => ( +
+
) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + item.onClick() + } + }}> +
+ {item.icon === "cask" ? ( + + ) : ( + + )} +
{item.title}
+ {item.badge && ( + + {item.badge} + + )} +
+
{item.subtitle}
+
+
+ {item.actions.map((action) => { + const isPending = pendingAction === `${action.id}-${item.title}` + const icon = isPending ? ( + + ) : action.id === "install" ? ( + + ) : action.id === "upgrade" ? ( + + ) : action.id === "uninstall" ? ( + + ) : action.id === "info" ? ( + + ) : action.id === "homepage" ? ( + + ) : null + return ( + + ) + })} +
+
+ ))} +
+ ) +} + +function DetailPanel({ + info, + width, + onClose, + onResizeStart, + busy, + output, + loading, + pendingAction, + onInstall, + onUninstall, + onUpgrade, +}: { + info: BrewInfo | null + width: number + onClose: () => void + onResizeStart: (event: ReactPointerEvent) => void + busy: boolean + output: string + loading?: boolean + pendingAction?: string | null + onInstall: (name: string, cask: boolean) => void + onUninstall: (name: string, cask: boolean) => void + onUpgrade: (name: string, cask: boolean) => void +}) { + if (!info) return null + + const resizeByKeyboard = (event: KeyboardEvent) => { + if (!["Home", "End"].includes(event.key)) return + event.preventDefault() + if (event.key === "Home") onClose() + // Simplified: no width keyboard resize for brevity + } + + const isInstalled = info.installed.length > 0 + const actionIdPrefix = `${info.name}` + + return ( +
+
+
+
+
{info.name}
+
+ {info.is_cask ? "Cask" : "Formula"} • {info.tap} +
+
+ +
+ +
+ {!isInstalled && ( + + )} + {isInstalled && ( + <> + + + + )} + {info.homepage && ( + + )} +
+ + {loading ? ( +
+
+
+
+
+
+
+
+ ) : ( +
+ {info.description && ( +

{info.description}

+ )} + + {info.versions.stable && ( +
+ Stable: {info.versions.stable} +
+ )} + {info.versions.head && ( +
+ Head: {info.versions.head} +
+ )} + + {info.installed.length > 0 && ( +
+
Installed versions
+
+ {info.installed.map((v, idx) => ( +
+ {v.version} + {v.installed_as_dependency && ( + dependency + )} + {v.installed_on_request && ( + on request + )} +
+ ))} +
+
+ )} + + {info.dependencies.length > 0 && ( +
+
Dependencies
+
+ {info.dependencies.map((dep) => ( + + {dep} + + ))} +
+
+ )} + + {info.caveats && ( +
+ {info.caveats} +
+ )} +
+ )} + +
+        {output || (busy ? "Working..." : "")}
+      
+
+ ) +} \ No newline at end of file diff --git a/src/plugins/homebrew.tsx b/src/plugins/homebrew.tsx new file mode 100644 index 0000000..cf1bf9b --- /dev/null +++ b/src/plugins/homebrew.tsx @@ -0,0 +1,245 @@ +import { invoke } from "@tauri-apps/api/core" +import { openUrl } from "@tauri-apps/plugin-opener" +import { Beer, Download, ArrowUpCircle, Trash2, Info, ExternalLink } from "lucide-react" +import { GQuickPlugin, SearchResultItem } from "./types" + +interface BrewPackage { + name: string + version: string + tap: string + desc?: string + homepage?: string + is_cask: boolean + outdated: boolean + latest_version?: string +} + +interface BrewSearchResult { + name: string + description?: string + version?: string + tap: string + is_cask: boolean +} + +export const BREW_SEARCH_DEBOUNCE_MS = 300 + +const BREW_PREFIX_PATTERN = /^brew\s*:/i + +function confirmRisk(message: string): boolean { + return window.confirm(message) +} + +function openHomebrew(opts?: { pkg?: { name: string; isCask: boolean }; searchTerm?: string }) { + window.dispatchEvent(new CustomEvent("gquick-open-homebrew", { detail: opts })) +} + +function getOpenHomebrewItem(subtitle = "Manage Homebrew packages", searchTerm?: string): SearchResultItem { + return { + id: "homebrew-open-page", + pluginId: "homebrew", + title: "Open Homebrew", + subtitle, + icon: Beer, + score: 120, + onSelect: () => openHomebrew({ searchTerm }), + } +} + +export const homebrewPlugin: GQuickPlugin = { + metadata: { + id: "homebrew", + title: "Homebrew", + icon: Beer, + keywords: ["brew", "homebrew", "package", "formula", "cask"], + queryPrefixes: ["brew:"], + }, + shouldSearch: (query: string) => { + return BREW_PREFIX_PATTERN.test(query.trim()) + }, + searchDebounceMs: BREW_SEARCH_DEBOUNCE_MS, + getItems: async (query: string): Promise => { + const trimmedQuery = query.trim() + if (!BREW_PREFIX_PATTERN.test(trimmedQuery)) { + return [] + } + + const searchTerm = trimmedQuery.replace(BREW_PREFIX_PATTERN, "").trim() + const q = searchTerm.toLowerCase() + + if (!q) { + return [getOpenHomebrewItem("Type brew: to search installed and remote packages.", searchTerm)] + } + + const items: SearchResultItem[] = [] + + let installedNames = new Set() + + try { + const installed = await invoke("brew_list") + installedNames = new Set(installed.map((p) => p.name.toLowerCase())) + installed + .filter((p) => p.name.toLowerCase().includes(q)) + .slice(0, 10) + .forEach((p) => { + const actions: NonNullable = [ + { + id: "upgrade", + label: "Upgrade", + onRun: () => { + if (confirmRisk(`Upgrade ${p.name}?`)) { + void invoke("brew_upgrade", { name: p.name, cask: p.is_cask }) + } + }, + }, + { + id: "uninstall", + label: "Uninstall", + onRun: () => { + if (confirmRisk(`Uninstall ${p.name}?`)) { + void invoke("brew_uninstall", { name: p.name, cask: p.is_cask, confirmed: true }) + } + }, + }, + { + id: "info", + label: "Info", + onRun: () => { + openHomebrew({ pkg: { name: p.name, isCask: p.is_cask }, searchTerm }) + }, + }, + { + id: "homepage", + label: "Open Homebrew Page", + onRun: () => { + if (p.homepage) { + void openUrl(p.homepage) + } + }, + }, + ] + + items.push({ + id: `brew-installed-${p.name}`, + pluginId: "homebrew", + title: p.name, + subtitle: `${p.is_cask ? "Cask" : "Formula"}: ${p.version}${p.outdated ? ` • outdated${p.latest_version ? ` (${p.latest_version} available)` : ""}` : ""}`, + icon: Beer, + onSelect: () => openHomebrew({ pkg: { name: p.name, isCask: p.is_cask }, searchTerm }), + actions, + score: p.outdated ? 105 : 100, + renderPreview: () => , + }) + }) + } catch { + items.push({ + id: "brew-local-error", + pluginId: "homebrew", + title: "Homebrew unavailable", + subtitle: "Open Homebrew page for status", + icon: Beer, + onSelect: () => openHomebrew({ searchTerm }), + score: 90, + }) + } + + if (q.length >= 2) { + try { + const remote = await invoke("brew_search", { query: searchTerm }) + remote + .filter((r) => !installedNames.has(r.name.toLowerCase())) + .slice(0, 10) + .forEach((r) => { + const actions: NonNullable = [ + { + id: "install", + label: "Install", + onRun: () => { + if (confirmRisk(`Install ${r.name}? This may take several minutes.`)) { + void invoke("brew_install", { name: r.name, cask: r.is_cask }) + } + }, + }, + { + id: "info", + label: "Info", + onRun: () => { + openHomebrew({ pkg: { name: r.name, isCask: r.is_cask }, searchTerm }) + }, + }, + { + id: "homepage", + label: "Open Homebrew Page", + onRun: () => { + void openUrl(`https://formulae.brew.sh/${r.is_cask ? "cask" : "formula"}/${r.name}`) + }, + }, + ] + + items.push({ + id: `brew-remote-${r.name}`, + pluginId: "homebrew", + title: r.name, + subtitle: `${r.is_cask ? "Cask" : "Formula"}${r.description ? ` • ${r.description}` : ""}`, + icon: Download, + onSelect: () => openHomebrew({ pkg: { name: r.name, isCask: r.is_cask }, searchTerm }), + actions, + score: 80, + renderPreview: () => , + }) + }) + } catch { + items.push({ + id: "brew-search-error", + pluginId: "homebrew", + title: "Homebrew search failed", + subtitle: "Local packages are still available", + icon: Beer, + onSelect: () => {}, + score: 20, + }) + } + } + + if (items.length === 0) { + items.push(getOpenHomebrewItem("No results found for brew: ", searchTerm)) + } + + return items + }, +} + +function ActionRow({ actions }: { actions: NonNullable }) { + return ( +
+ {actions.map((action) => { + const icon = + action.id === "install" ? ( + + ) : action.id === "upgrade" ? ( + + ) : action.id === "uninstall" ? ( + + ) : action.id === "info" ? ( + + ) : action.id === "homepage" ? ( + + ) : null + return ( + + ) + })} +
+ ) +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 568d631..9c74a01 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,6 +1,7 @@ import { appLauncherPlugin } from "./appLauncher"; import { calculatorPlugin } from "./calculator"; import { dockerPlugin } from "./docker"; +import { homebrewPlugin } from "./homebrew"; import { webSearchPlugin } from "./webSearch"; import { fileSearchPlugin } from "./fileSearch"; import { recentFilesPlugin } from "./recentFiles"; @@ -17,6 +18,7 @@ export const plugins: GQuickPlugin[] = [ fileSearchPlugin, calculatorPlugin, dockerPlugin, + homebrewPlugin, webSearchPlugin, translatePlugin, notesPlugin, diff --git a/src/plugins/networkInfo.tsx b/src/plugins/networkInfo.tsx index 709d51c..d4e65cf 100644 --- a/src/plugins/networkInfo.tsx +++ b/src/plugins/networkInfo.tsx @@ -116,7 +116,7 @@ export const networkInfoPlugin: GQuickPlugin = { title: "Network info", subtitle: "IP, Wi-Fi, and latency", icon: Network, - keywords: ["net", "network", "ip", "wifi", "ping", "latency"], + keywords: ["net", "network", "wifi", "wi-fi"], queryPrefixes: [/^(net|network)(:|\b)/i, /^(wifi|wi-fi)$/i], }, shouldSearch: isNetworkQuery, diff --git a/src/plugins/notes.tsx b/src/plugins/notes.tsx index 4b08455..e3cc1f3 100644 --- a/src/plugins/notes.tsx +++ b/src/plugins/notes.tsx @@ -28,7 +28,7 @@ export const notesPlugin: GQuickPlugin = { id: "notes", title: "Notes", icon: StickyNote, - keywords: ["note", "memo", "remember", "save"], + keywords: ["note", "notes", "memo"], queryPrefixes: ["note:", "notes:", "search notes:"], }, tools: [ diff --git a/src/plugins/speedtest.tsx b/src/plugins/speedtest.tsx index edcde82..9efc858 100644 --- a/src/plugins/speedtest.tsx +++ b/src/plugins/speedtest.tsx @@ -416,7 +416,7 @@ export const speedtestPlugin: GQuickPlugin = { title: "Speedtest", subtitle: "Latency, download, and upload via Cloudflare", icon: Gauge, - keywords: ["speedtest", "speed test", "internet speed", "/st", "ping", "latency", "download", "upload"], + keywords: ["speedtest", "speed test", "internet speed", "/st"], queryPrefixes: [/^(speedtest|speed test|internet speed|\/st)$/i], }, shouldSearch: isSpeedtestQuery, @@ -562,3 +562,11 @@ function SpeedtestSubtitle() { const current = useSpeedtestSnapshot(); return <>{current.phase} • {Math.round(current.progress)}% • Select to {current.running ? "view" : "start"}; } + +export function isSpeedtestRunning(): boolean { + return snapshot.running && controller !== null; +} + +export function cancelSpeedtest(): void { + stopSpeedtest("Stopped by user."); +} diff --git a/src/plugins/translate.tsx b/src/plugins/translate.tsx index b946a65..2cbf112 100644 --- a/src/plugins/translate.tsx +++ b/src/plugins/translate.tsx @@ -268,7 +268,7 @@ export const translatePlugin: GQuickPlugin = { title: "Translate", subtitle: "AI-powered text translation", icon: Languages, - keywords: ["translate", "translation", "language", "convert text"], + keywords: ["translate", "translation"], queryPrefixes: ["translate:", "/tr", "tr:"], }, shouldSearch: (query: string) => { diff --git a/src/plugins/weather.tsx b/src/plugins/weather.tsx index 5b4e510..9865875 100644 --- a/src/plugins/weather.tsx +++ b/src/plugins/weather.tsx @@ -270,7 +270,7 @@ export const weatherPlugin: GQuickPlugin = { title: "Weather", subtitle: "Weather forecast", icon: CloudSun, - keywords: ["weather", "forecast", "temperature", "rain", "sun", "climate"], + keywords: ["weather", "forecast"], queryPrefixes: ["/wt", "weather:"], }, tools: [