diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fc98fc4..26f24ed6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: run: npm run build raycast-check-macos: - name: Raycast tests (macOS) + name: Raycast lint, test, and build (macOS) runs-on: macos-latest defaults: run: @@ -104,9 +104,18 @@ jobs: - name: Install dependencies run: npm ci + - name: Verify Raycast CLI + run: node scripts/verify-raycast-cli.js + - name: Test run: npm test + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + # Informational only: wall-clock medians vary by runner hardware. Never gate PRs on ms budgets. # Critical-path contracts remain in build-test. Artifacts support manual regression diffs. perf-harness: diff --git a/AGENTS.md b/AGENTS.md index ab8e9302..69431630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidance for coding agents working in QuickShell. Architecture tours live in `do ## Project Overview -Quick Shell is a **Windows-only .NET 10 keyboard-first workspace launcher**. A *workspace* is a saved folder plus metadata (terminal launches, companion app, git target, dev server). The primary surface is a PowerToys Command Palette extension (`QuickShell`), packaged out-of-process as a signed MSIX COM server. Two sibling hosts reuse the same on-disk model: a PowerToys Run plugin (`QuickShell.Run`, `qs` keyword) and a TypeScript Raycast extension (`QuickShell.Raycast`, parallel storage). `QuickShell.Core` owns all domain logic (no CmdPal SDK dependency) so the hosts are swappable UI shells. `QuickShell.Suggest` is a console CLI that emits JSON suggestion pills for Raycast. +Quick Shell's .NET desktop hosts are **Windows-only**; `QuickShell.Raycast` is a separate TypeScript host with macOS support. A _workspace_ is a saved folder plus metadata (terminal launches, companion app, git target, dev server). The primary surface is a PowerToys Command Palette extension (`QuickShell`), packaged out-of-process as a signed MSIX COM server. Two sibling hosts reuse the same on-disk model: a PowerToys Run plugin (`QuickShell.Run`, `qs` keyword) and a TypeScript Raycast extension (`QuickShell.Raycast`, parallel storage). `QuickShell.Core` owns all domain logic (no CmdPal SDK dependency) so the hosts are swappable UI shells. `QuickShell.Suggest` is a console CLI that emits JSON suggestion pills for Raycast. The UI term is **workspace**; the on-disk file is still `%LOCALAPPDATA%\QuickShell\shortcuts.json`. Keep the product term and the storage term separate in code/comments. @@ -36,17 +36,17 @@ The UI term is **workspace**; the on-disk file is still `%LOCALAPPDATA%\QuickShe ## Key Directories -| Path | Purpose | -| ------ | --------- | -| `QuickShell.Core/` | Domain: models, persistence, launch, health, git, terminals, classification, suggestions, companions. **No** CmdPal SDK dependency. `Services/`, `Models/`, `Composition/`, `Classification/`, `Abstractions/`. | -| `QuickShell/` | CmdPal extension: MSIX, Adaptive Card pages, command routing. `Pages/`, `Commands/`, `Services/CommandRouting/`, `Program.cs`, `QuickShell.cs`, `QuickShellCommandsProvider.cs`. | -| `QuickShell.Run/` | PowerToys Run plugin (`IPlugin`, `qs` keyword); consumes Core. | -| `QuickShell.Core.Tests/` | xUnit unit tests for Core (Windows-only TFM). | -| `QuickShell.Raycast/` | Separate npm/TS extension; **not** in the `.sln`; mirrors product rules, shells out to `QuickShell.Suggest`. | -| `QuickShell.Suggest/` | Console CLI emitting JSON suggestion pills for Raycast. | -| `scripts/` | `deploy.ps1`, `run-cmdpal-dev.ps1`, `deploy-all.ps1`/`ddeploy.ps1`, `generate-assets.ps1`, `RaycastLifecycle.ps1`, `build-exe.ps1`, `setup-template.iss`, `LogoAssetGenerator/`. | -| `docs/architecture/` | As-built tours (`overview`, `launch`, `persistence`, `cmdpal-surface`, `hosts`, `settings`, `forms`, `intelligence`, `companions`, `git-and-discover`) + ADRs `0001`-`0005`. | -| `.github/workflows/` | `ci.yml` (build/test), `release-extension.yml` (tag-triggered release + WinGet). | +| Path | Purpose | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QuickShell.Core/` | Domain: models, persistence, launch, health, git, terminals, classification, suggestions, companions. **No** CmdPal SDK dependency. `Services/`, `Models/`, `Composition/`, `Classification/`, `Abstractions/`. | +| `QuickShell/` | CmdPal extension: MSIX, Adaptive Card pages, command routing. `Pages/`, `Commands/`, `Services/CommandRouting/`, `Program.cs`, `QuickShell.cs`, `QuickShellCommandsProvider.cs`. | +| `QuickShell.Run/` | PowerToys Run plugin (`IPlugin`, `qs` keyword); consumes Core. | +| `QuickShell.Core.Tests/` | xUnit unit tests for Core (Windows-only TFM). | +| `QuickShell.Raycast/` | Separate npm/TS extension; **not** in the `.sln`; mirrors product rules, shells out to `QuickShell.Suggest`. | +| `QuickShell.Suggest/` | Console CLI emitting JSON suggestion pills for Raycast. | +| `scripts/` | `deploy.ps1`, `run-cmdpal-dev.ps1`, `deploy-all.ps1`/`ddeploy.ps1`, `generate-assets.ps1`, `RaycastLifecycle.ps1`, `build-exe.ps1`, `setup-template.iss`, `LogoAssetGenerator/`. | +| `docs/architecture/` | As-built tours (`overview`, `launch`, `persistence`, `cmdpal-surface`, `hosts`, `settings`, `forms`, `intelligence`, `companions`, `git-and-discover`) + ADRs `0001`-`0005`. | +| `.github/workflows/` | `ci.yml` (build/test), `release-extension.yml` (tag-triggered release + WinGet). | ## Development Commands @@ -121,7 +121,7 @@ npm run dev # ray develop ## Runtime / Tooling Preferences - **.NET 10 SDK** (no `global.json`; SDK version implied). Target frameworks differ by project: the `QuickShell` CmdPal host targets `net10.0-windows10.0.26100.0`; `QuickShell.Core`, `QuickShell.Core.Tests`, and `QuickShell.Suggest` target `net10.0-windows7.0`. All are Windows-only (CsWinRT/CsWin32, Windows App SDK, WinUI, MSIX tooling). -- **`QuickShell.Core` has `true`** despite owning no CmdPal SDK dependency. It uses WinForms for clipboard/path pickers. So Core is only *compilable* off-Windows (via `-p:EnableWindowsTargeting=true`); it cannot *execute* on Linux. Keep Windows-only APIs in Core minimal so the swappable-host story holds. +- **`QuickShell.Core` has `true`** despite owning no CmdPal SDK dependency. It uses WinForms for clipboard/path pickers. So Core is only _compilable_ off-Windows (via `-p:EnableWindowsTargeting=true`); it cannot _execute_ on Linux. Keep Windows-only APIs in Core minimal so the swappable-host story holds. - **Package manager:** NuGet with **Central Package Management** (`Directory.Packages.props`, `ManagePackageVersionsCentrally=true`). CmdPal SDK is `Microsoft.CommandPalette.Extensions` (NuGet) or a sibling local PowerToys SDK via `-p:UseLocalCmdPalSdk=true` (defines `CMDPAL_HOVER_ACTIONS`; don't assume those APIs exist otherwise). - **No `.editorconfig` or `global.json`.** Analyzers are on: `EnableNETAnalyzers=true`, `AnalysisMode=Recommended`, plus StyleCop. Treat analyzer warnings seriously; they can break the Windows build. - **Node >= 22.14** for the Raycast surface; `npm`/`ray` CLI for its build/lint/test. diff --git a/QuickShell.Raycast/CHANGELOG.md b/QuickShell.Raycast/CHANGELOG.md index 4fe15f73..ef6d997a 100644 --- a/QuickShell.Raycast/CHANGELOG.md +++ b/QuickShell.Raycast/CHANGELOG.md @@ -3,8 +3,10 @@ ## [macOS Tier A] - {PR_MERGE_DATE} - Add macOS to Raycast platforms: Terminal.app / iTerm2 launch, Mac companions, discover roots, import/export dialogs -- Multi-launch on Mac opens separate windows; Windows Terminal tab grouping unchanged on Windows -- Dual-platform keyboard shortcuts; CI macOS test job alongside Windows lint/build +- Multi-launch tabs on Mac (Terminal.app / iTerm2) when the preference is enabled; Windows Terminal tab grouping unchanged on Windows +- Open Directory allows POSIX paths; hide Run as administrator on Mac +- Richer Mac discover roots (`~/Code`, `~/Library/Developer`, Desktop/Documents Projects, …) +- Dual-platform keyboard shortcuts; CI macOS lint/test/build alongside Windows - Distribution is Raycast Store only (no GitHub Release / WinGet sideload packages) ## [Preferences, useForm, and navigation] - 2026-07-06 diff --git a/QuickShell.Raycast/README.md b/QuickShell.Raycast/README.md index 00d319af..6ad83c43 100644 --- a/QuickShell.Raycast/README.md +++ b/QuickShell.Raycast/README.md @@ -6,7 +6,7 @@ Raycast-native workspace launcher for Quick Shell on **Windows** and **macOS**. - **Quick Shell** — search, launch, create, discover git repos, edit, favorite, duplicate, import/export, undo/redo, preferences -**Extension preferences** (Raycast → Extensions → Quick Shell): default terminal app, default profile, show recents, multi-command tabs (Windows), block dirty branch switch. +**Extension preferences** (Raycast → Extensions → Quick Shell): default terminal app, default profile, show recents, multi-command tabs, block dirty branch switch. Root search: the command is titled **Quick Shell**; its subtitle shows your 3 most recent workspaces. Register it as a fallback command so root-search text seeds the list via `fallbackText`. Open the command and use Actions (Ctrl+K) for Recent / Import / Export. Use **Add to Root Search** on a workspace to create a Quicklink. @@ -17,7 +17,7 @@ Create, Discover, and Edit are Actions / pushed views inside Quick Shell (not se - **Raycast** for Windows or macOS - **Node.js 22.14+** (development only) - Windows: Windows Terminal, PowerShell, or WSL -- macOS: Terminal.app and/or iTerm2 (multi-launch opens separate windows) +- macOS: Terminal.app and/or iTerm2 (multi-launch can open as tabs in one window) ## File structure @@ -75,4 +75,4 @@ Run `npm run build` before submitting Store changes. Do not add a `version` fiel Workspaces live in Raycast `LocalStorage` (`quickshell-data`), not shared `%LOCALAPPDATA%\QuickShell\` files. Use **Export Workspaces…** / **Import Workspaces…** (JSON files) to bridge with CmdPal/Run. -Raycast-local parity includes: trust/import contracts, Suggest.exe pills with local fallback (Suggest is Windows-only; Mac uses heuristics), multi-companion form + presets, terminal-host and port-in-use health warnings, copyable launch diagnostics, git launch gate (`branchTargets` + `blockDirtyBranchSwitch`), and layout section separators. Intentional gaps remain: shared LocalAppData stores / Core `worktree-branch-targets.json`, process-list health, ETW, Windows Terminal-style tab grouping on macOS. +Raycast-local parity includes: trust/import contracts, Suggest.exe pills with local fallback (Suggest is Windows-only; Mac uses heuristics), multi-companion form + presets, terminal-host and port-in-use health warnings, copyable launch diagnostics, git launch gate (`branchTargets` + `blockDirtyBranchSwitch`), layout section separators, and multi-launch tabs (Windows Terminal on Windows; Terminal.app / iTerm2 on macOS). Intentional gaps remain: shared LocalAppData stores / Core `worktree-branch-targets.json`, process-list health, ETW, and Suggest CLI on macOS. diff --git a/QuickShell.Raycast/package.json b/QuickShell.Raycast/package.json index 5568a8a3..a92e0956 100644 --- a/QuickShell.Raycast/package.json +++ b/QuickShell.Raycast/package.json @@ -82,11 +82,11 @@ { "name": "singleWindowTabs", "title": "Multi-command Launch", - "description": "When supported on Windows, open multiple launch commands as tabs in one Windows Terminal window. On macOS, launches always open as separate windows.", + "description": "Open multiple launch commands as tabs in one window (Windows Terminal on Windows; Terminal.app or iTerm2 on macOS).", "type": "checkbox", "required": false, "default": true, - "label": "Open multiple commands in one Windows Terminal window" + "label": "Open multiple commands as tabs in one window" }, { "name": "blockDirtyBranchSwitch", diff --git a/QuickShell.Raycast/raycast-env.d.ts b/QuickShell.Raycast/raycast-env.d.ts index c421290e..c6dd6e73 100644 --- a/QuickShell.Raycast/raycast-env.d.ts +++ b/QuickShell.Raycast/raycast-env.d.ts @@ -14,7 +14,7 @@ type ExtensionPreferences = { "defaultProfile": string, /** Recent Workspaces - Show recently opened workspaces in Workspaces. */ "showRecents": boolean, - /** Multi-command Launch - When supported on Windows, open multiple launch commands as tabs in one Windows Terminal window. On macOS, launches always open as separate windows. */ + /** Multi-command Launch - Open multiple launch commands as tabs in one window (Windows Terminal on Windows; Terminal.app or iTerm2 on macOS). */ "singleWindowTabs": boolean, /** Git Branch Gate - Block launch when a target branch is set and the working tree has uncommitted changes. */ "blockDirtyBranchSwitch": boolean diff --git a/QuickShell.Raycast/src/__tests__/extension-preferences.test.ts b/QuickShell.Raycast/src/__tests__/extension-preferences.test.ts index 4d8f704a..648332cc 100644 --- a/QuickShell.Raycast/src/__tests__/extension-preferences.test.ts +++ b/QuickShell.Raycast/src/__tests__/extension-preferences.test.ts @@ -51,14 +51,20 @@ describe("extension-preferences", () => { expect(windows.multiLaunchPresentation).toBe("separateWindows"); }); - it("normalizes Windows terminals to Terminal.app on macOS and forces separate windows", () => { + it("normalizes Windows terminals to Terminal.app on macOS and respects tab preference", () => { Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); const settings = preferencesToSettings({ terminalApplication: "wt", singleWindowTabs: true, }); expect(settings.terminalApplication).toBe("terminal"); - expect(settings.multiLaunchPresentation).toBe("separateWindows"); + expect(settings.multiLaunchPresentation).toBe("singleWindowTabs"); + + const separate = preferencesToSettings({ + terminalApplication: "terminal", + singleWindowTabs: false, + }); + expect(separate.multiLaunchPresentation).toBe("separateWindows"); }); it("accepts iterm on macOS", () => { diff --git a/QuickShell.Raycast/src/__tests__/git-repo-search-roots-mac.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-search-roots-mac.test.ts index f42c80f4..616b2701 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-search-roots-mac.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-search-roots-mac.test.ts @@ -21,6 +21,13 @@ describe("git-repo-search-roots macOS", () => { "/Users/dev/Developer", "/Users/dev/Documents", "/Users/dev/Documents/GitHub", + "/Users/dev/Code", + "/Users/dev/Sites", + "/Users/dev/GitHub", + "/Users/dev/Library/Developer", + "/Users/dev/Documents/Projects", + "/Users/dev/Desktop/Projects", + "/Users/dev/Desktop/Developer", ]), ); expect(candidates.some((candidate) => /^[a-zA-Z]:\\/.test(candidate))).toBe(false); diff --git a/QuickShell.Raycast/src/__tests__/import-export.test.ts b/QuickShell.Raycast/src/__tests__/import-export.test.ts index 05a7d68c..d2c8c0dd 100644 --- a/QuickShell.Raycast/src/__tests__/import-export.test.ts +++ b/QuickShell.Raycast/src/__tests__/import-export.test.ts @@ -110,4 +110,203 @@ describe("import-export", () => { expect(result.data.layoutEntries?.[0]).toMatchObject({ type: "separator", id: separatorId, title: "Apps" }); expect(result.data.layoutEntries?.some((entry) => entry.type === "workspace")).toBe(true); }); + + it("appends imported CmdPal separators when merging into existing layout", () => { + const existingId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const existing = createEmptyStoredData(); + existing.workspaces.push( + normalizeWorkspace({ + id: existingId, + name: "Local", + abbreviation: null, + directory: "C:\\Projects\\local", + isPinned: false, + pinOrder: null, + lastUsedUtc: null, + terminal: "default", + wtProfile: null, + command: null, + runAsAdmin: false, + launches: [], + }), + ); + existing.layoutEntries = [ + { type: "separator", id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", title: "Mine" }, + { type: "workspace", workspaceId: existingId }, + ]; + + const result = importParsedPayload( + { + version: 1, + entries: [ + { + Id: "cccccccccccccccccccccccccccccccc", + Name: "Imported", + Directory: "C:\\Projects\\imported", + }, + { Type: "separator", Title: "From desktop" }, + { + Id: "dddddddddddddddddddddddddddddddd", + Name: "Other", + Directory: "C:\\Projects\\other", + }, + ], + }, + existing, + ); + + expect(result.imported).toBe(2); + expect(result.data.layoutEntries).toEqual([ + { type: "separator", id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", title: "Mine" }, + { type: "workspace", workspaceId: existingId }, + { type: "workspace", workspaceId: result.data.workspaces[1].id }, + { type: "separator", id: expect.any(String), title: "From desktop" }, + { type: "workspace", workspaceId: result.data.workspaces[2].id }, + ]); + }); + + it("imports CmdPal layout envelope with flat PascalCase shortcuts", () => { + const result = importParsedPayload({ + version: 1, + entries: [ + { + Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Name: "Frontend", + Directory: "C:\\Projects\\web", + Command: "npm run dev", + Terminal: "wt", + Launches: [ + { + Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Label: "Web", + Terminal: "wt", + Command: "npm run dev", + RunAsAdmin: false, + IsEnabled: true, + Order: 0, + TaskType: "none", + }, + ], + }, + { Type: "separator", Title: "Apps" }, + { + Id: "cccccccccccccccccccccccccccccccc", + Name: "API", + Directory: "C:\\Projects\\api", + Terminal: "default", + }, + ], + }); + + expect(result.imported).toBe(2); + expect(result.data.workspaces.map((workspace) => workspace.name)).toEqual(["Frontend", "API"]); + expect(result.data.workspaces[0].launches[0]?.label).toBe("Web"); + expect(result.data.layoutEntries).toEqual([ + { type: "workspace", workspaceId: result.data.workspaces[0].id }, + { type: "separator", id: expect.any(String), title: "Apps" }, + { type: "workspace", workspaceId: result.data.workspaces[1].id }, + ]); + }); + + it("omits skipped duplicate CmdPal entries from layout without shifting later rows", () => { + const result = importParsedPayload({ + version: 1, + entries: [ + { + Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Name: "Dup", + Directory: "C:\\Projects\\a", + }, + { Type: "separator", Title: "Mid" }, + { + Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Name: "Dup", + Directory: "C:\\Projects\\b", + }, + { + Id: "cccccccccccccccccccccccccccccccc", + Name: "Dup", + Directory: "C:\\Projects\\c", + }, + { + Id: "dddddddddddddddddddddddddddddddd", + Name: "Other", + Directory: "C:\\Projects\\d", + }, + ], + }); + + // First kept, second renamed, third skipped, fourth kept. + expect(result.imported).toBe(3); + expect(result.skipped).toBe(1); + expect(result.renamed).toBe(1); + expect(result.data.workspaces.map((workspace) => workspace.name)).toEqual(["Dup", "Dup (imported)", "Other"]); + expect(result.data.layoutEntries).toEqual([ + { type: "workspace", workspaceId: result.data.workspaces[0].id }, + { type: "separator", id: expect.any(String), title: "Mid" }, + { type: "workspace", workspaceId: result.data.workspaces[1].id }, + { type: "workspace", workspaceId: result.data.workspaces[2].id }, + ]); + }); + + it("assigns unique ids when CmdPal entries repeat the same source id", () => { + const sharedId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const result = importParsedPayload({ + version: 1, + entries: [ + { + Id: sharedId, + Name: "First", + Directory: "C:\\Projects\\a", + }, + { Type: "separator", Title: "Mid" }, + { + Id: sharedId, + Name: "Second", + Directory: "C:\\Projects\\b", + }, + ], + }); + + expect(result.imported).toBe(2); + expect(result.data.workspaces[0].id).not.toBe(result.data.workspaces[1].id); + expect(result.data.layoutEntries).toEqual([ + { type: "workspace", workspaceId: result.data.workspaces[0].id }, + { type: "separator", id: expect.any(String), title: "Mid" }, + { type: "workspace", workspaceId: result.data.workspaces[1].id }, + ]); + }); + + it("imports on-disk CmdPal shortcuts.json Workspace/Security wrappers", () => { + const result = importParsedPayload({ + version: 1, + entries: [ + { + Workspace: { + Id: "dddddddddddddddddddddddddddddddd", + Name: "Trackdub", + Directory: "D:\\Dev\\Trackdub", + Terminal: "default", + Launches: [ + { + Id: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + Label: "Launch", + Terminal: "default", + Command: "cline", + RunAsAdmin: false, + IsEnabled: true, + Order: 0, + }, + ], + }, + Security: { IsTrusted: true, Revision: 3 }, + }, + ], + }); + + expect(result.imported).toBe(1); + expect(result.data.workspaces[0].name).toBe("Trackdub"); + expect(result.data.workspaces[0].directory).toBe("D:\\Dev\\Trackdub"); + expect(result.data.workspaces[0].launches[0]?.command).toBe("cline"); + }); }); diff --git a/QuickShell.Raycast/src/__tests__/launch-executor.test.ts b/QuickShell.Raycast/src/__tests__/launch-executor.test.ts index e977f24f..b2b5a00b 100644 --- a/QuickShell.Raycast/src/__tests__/launch-executor.test.ts +++ b/QuickShell.Raycast/src/__tests__/launch-executor.test.ts @@ -144,7 +144,7 @@ describe("launch-executor", () => { }); }); - it("launches via open/osascript on macOS (separate windows)", async () => { + it("launches via open/osascript on macOS (tabs when preferred)", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); const calls: Array<{ command: string; args: string[] }> = []; @@ -191,6 +191,64 @@ describe("launch-executor", () => { const plan = buildWorkspaceLaunchPlan(macWorkspace, macSettings); const result = await executeWorkspaceLaunch(plan, macSettings, execFn); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].command).toBe("osascript"); + expect(calls[0].args[1]).toContain("in front window"); + expect(calls[0].args[1]).toContain("npm run dev"); + expect(calls[0].args[1]).toContain("npm run api"); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + }); + + it("opens separate Mac windows when multiLaunchPresentation is separateWindows", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); + const calls: Array<{ command: string; args: string[] }> = []; + const execFn: ExecFn = async (command, args) => { + calls.push({ command, args }); + }; + + try { + const macWorkspace: Workspace = { + ...workspace, + directory: "/Users/dev/Projects/web", + terminal: "terminal", + launches: [ + { + id: "1a", + label: "Web", + terminal: "terminal", + wtProfile: null, + command: "npm run dev", + runAsAdmin: false, + isEnabled: true, + order: 0, + taskType: "none", + }, + { + id: "1b", + label: "API", + terminal: "terminal", + wtProfile: null, + command: "npm run api", + runAsAdmin: false, + isEnabled: true, + order: 1, + taskType: "none", + }, + ], + }; + const macSettings: QuickShellSettings = { + ...settings, + terminalApplication: "terminal", + multiLaunchPresentation: "separateWindows", + }; + const { buildWorkspaceLaunchPlan } = await import("../lib/windows-launch"); + const plan = buildWorkspaceLaunchPlan(macWorkspace, macSettings); + const result = await executeWorkspaceLaunch(plan, macSettings, execFn); + expect(result.ok).toBe(true); expect(calls).toHaveLength(2); expect(calls.every((call) => call.command === "osascript")).toBe(true); diff --git a/QuickShell.Raycast/src/__tests__/mac-launch.test.ts b/QuickShell.Raycast/src/__tests__/mac-launch.test.ts index 1ef6cccc..c695af3a 100644 --- a/QuickShell.Raycast/src/__tests__/mac-launch.test.ts +++ b/QuickShell.Raycast/src/__tests__/mac-launch.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { buildMacLaunchInvocation, buildMacShellCommand, + buildMacTabbedLaunchInvocation, + groupMacLaunchEntries, normalizeMacTerminalApplication, resolveMacTerminalHostId, shellQuoteForZsh, } from "../lib/mac-launch"; import type { QuickShellSettings } from "../lib/schema"; +import type { LaunchPlanEntry } from "../lib/windows-launch"; const settings: QuickShellSettings = { terminalApplication: "terminal", @@ -16,6 +19,49 @@ const settings: QuickShellSettings = { blockDirtyBranchSwitch: true, }; +function entry(partial: { + directory: string; + command: string | null; + launch?: LaunchPlanEntry["launch"]; +}): LaunchPlanEntry { + const launch = partial.launch ?? { + id: "1", + label: "Launch", + terminal: "terminal", + wtProfile: null, + command: partial.command, + runAsAdmin: false, + isEnabled: true, + order: 0, + taskType: "none" as const, + }; + return { + workspace: { + id: "ws", + name: "Workspace", + directory: partial.directory, + isPinned: false, + pinOrder: null, + lastUsedUtc: null, + abbreviation: null, + terminal: "terminal", + wtProfile: null, + command: partial.command, + runAsAdmin: false, + launches: [launch], + }, + launch, + directory: partial.directory, + command: partial.command, + runAsAdmin: false, + target: { + kind: "wt", + hostExecutable: "wt.exe", + displayName: "Terminal", + }, + }; +} + describe("mac-launch", () => { it("quotes paths for zsh", () => { expect(shellQuoteForZsh("/Users/dev/My Project")).toBe("'/Users/dev/My Project'"); @@ -48,4 +94,78 @@ describe("mac-launch", () => { expect(invocation.args[1]).toContain('tell application "Terminal"'); expect(invocation.args[1]).toContain("npm test"); }); + + it("groups compatible Mac launches into one tabbed window", () => { + const entries = [ + entry({ + directory: "/Users/dev/a", + command: "npm run web", + launch: { + id: "a", + label: "Web", + terminal: "terminal", + wtProfile: null, + command: "npm run web", + runAsAdmin: false, + isEnabled: true, + order: 0, + taskType: "none", + }, + }), + entry({ + directory: "/Users/dev/a", + command: "npm run api", + launch: { + id: "b", + label: "API", + terminal: "terminal", + wtProfile: null, + command: "npm run api", + runAsAdmin: false, + isEnabled: true, + order: 1, + taskType: "none", + }, + }), + entry({ + directory: "/Users/dev/b", + command: "npm test", + launch: { + id: "c", + label: "Test", + terminal: "iterm", + wtProfile: null, + command: "npm test", + runAsAdmin: false, + isEnabled: true, + order: 2, + taskType: "none", + }, + }), + ]; + + const groups = groupMacLaunchEntries(entries, settings, false); + expect(groups).toHaveLength(2); + expect(groups[0].hostId).toBe("terminal"); + expect(groups[0].entries).toHaveLength(2); + expect(groups[1].hostId).toBe("iterm"); + expect(groups[1].entries).toHaveLength(1); + + const tabbed = buildMacTabbedLaunchInvocation(groups[0].entries, "terminal"); + expect(tabbed.executable).toBe("osascript"); + expect(tabbed.args[1]).toContain("do script"); + expect(tabbed.args[1]).toContain("in front window"); + expect(tabbed.args[1]).toContain("npm run web"); + expect(tabbed.args[1]).toContain("npm run api"); + }); + + it("keeps separate windows when preferred", () => { + const entries = [ + entry({ directory: "/Users/dev/a", command: "one" }), + entry({ directory: "/Users/dev/a", command: "two" }), + ]; + const groups = groupMacLaunchEntries(entries, settings, true); + expect(groups).toHaveLength(2); + expect(groups.every((group) => group.entries.length === 1)).toBe(true); + }); }); diff --git a/QuickShell.Raycast/src/__tests__/security.test.ts b/QuickShell.Raycast/src/__tests__/security.test.ts index 3602426d..3b13b094 100644 --- a/QuickShell.Raycast/src/__tests__/security.test.ts +++ b/QuickShell.Raycast/src/__tests__/security.test.ts @@ -250,9 +250,10 @@ describe("workspace security policy", () => { }); it("resolves each open-on-launch companion by ID and suppresses invalid effects", () => { + const directory = createTempDirectory(); const content: Workspace = { ...workspace, - directory: "\\\\wsl$\\Ubuntu\\home\\dev\\project", + directory, devServerUrl: "https://localhost:5173/?next=a&mode=b", openDevServerOnLaunch: true, companionApps: [ @@ -285,7 +286,89 @@ describe("workspace security policy", () => { expect(effects.warnings).toHaveLength(1); }); + it("includes all configured companions when companionSelection is all", () => { + const directory = createTempDirectory(); + const content: Workspace = { + ...workspace, + directory, + companionApps: [ + { + id: "on-launch", + path: process.execPath, + arguments: null, + openOnLaunch: true, + order: 0, + }, + { + id: "on-demand", + path: process.execPath, + arguments: "--extra", + openOnLaunch: false, + order: 1, + }, + ], + }; + + const openOnLaunchOnly = authorizePostLaunchEffects({ ...stored(true), content }); + const allCompanions = authorizePostLaunchEffects( + { ...stored(true), content }, + { includeCompanion: true, includeDevServer: false, companionSelection: "all" }, + ); + + expect(openOnLaunchOnly.plan.companions.map((entry) => entry.companionId)).toEqual(["on-launch"]); + expect(allCompanions.plan.companions.map((entry) => entry.companionId)).toEqual(["on-launch", "on-demand"]); + expect(allCompanions.plan.devServerUrl).toBeNull(); + }); + + it("blocks companion launch when a local workspace directory is missing", () => { + const content: Workspace = { + ...workspace, + directory: "Z:\\QuickShell\\DefinitelyMissingWorkspace", + companionApps: [ + { + id: "node", + path: process.execPath, + arguments: null, + openOnLaunch: true, + order: 0, + }, + ], + }; + const value = { ...stored(true), content }; + + expect(authorize(value, { kind: "companion", companionId: "node" }).primaryIssueCode).toBe("DirectoryMissing"); + + const effects = authorizePostLaunchEffects(value, { + includeCompanion: true, + includeDevServer: false, + companionSelection: "all", + }); + expect(effects.plan.companions).toEqual([]); + expect(effects.warnings.length).toBeGreaterThan(0); + }); + + it("blocks companion launch when a WSL workspace directory is missing", () => { + const content: Workspace = { + ...workspace, + directory: "\\\\wsl$\\Ubuntu\\home\\dev\\QuickShellMissingCompanionDir", + companionApps: [ + { + id: "node", + path: process.execPath, + arguments: null, + openOnLaunch: true, + order: 0, + }, + ], + }; + const value = { ...stored(true), content }; + + expect(authorize(value, { kind: "companion", companionId: "node" }).primaryIssueCode).toBe("DirectoryMissing"); + expect(authorize(value, { kind: "terminal" }).isAllowed).toBe(true); + }); + it("fails closed for ambiguous duplicate companion IDs", () => { + const directory = createTempDirectory(); const duplicate = { id: "duplicate", path: process.execPath, @@ -295,7 +378,7 @@ describe("workspace security policy", () => { }; const content: Workspace = { ...workspace, - directory: "\\\\wsl$\\Ubuntu\\home\\dev\\project", + directory, companionApps: [duplicate, { ...duplicate, arguments: "--second", order: 1 }], }; const value = { ...stored(true), content }; @@ -326,16 +409,35 @@ describe("workspace trust kill switch (disabled)", () => { expect(authorize(value, { kind: "launchEntry", launchId: "launch-1" }).isAllowed).toBe(true); expect(authorize(value, { kind: "url", url: "https://example.com" }).isAllowed).toBe(true); - // Open-directory still requires an existing rooted Windows drive path (product is - // Windows-only). On Linux CI assert trust is not the blocker; on Windows assert allow. + // Open-directory allows rooted Windows drive paths and POSIX absolute paths. const openDirectory = authorize(value, { kind: "directory" }); expect(openDirectory.issues.some((issue) => issue.code === "WorkspaceUntrusted")).toBe(false); - if (process.platform === "win32") { - expect(openDirectory.isAllowed).toBe(true); - } else { - expect(openDirectory.isAllowed).toBe(false); - expect(openDirectory.primaryIssueCode).toBe("DirectoryOpenNotAllowed"); - } + expect(openDirectory.isAllowed).toBe(true); + }); + + it("open-directory allows rooted Windows drive paths and rejects double-slash paths", () => { + setWorkspaceTrustEnabledForTests(false); + const driveRoot = createTempDirectory(); + const allowed = authorize( + { + content: { ...workspace, directory: driveRoot }, + security: { isTrusted: false, revision: 1 }, + revision: 1, + }, + { kind: "directory" }, + ); + expect(allowed.isAllowed).toBe(true); + + const rejected = authorize( + { + content: { ...workspace, directory: "//unc-style/share/project" }, + security: { isTrusted: false, revision: 1 }, + revision: 1, + }, + { kind: "directory" }, + ); + expect(rejected.isAllowed).toBe(false); + expect(["InvalidDirectory", "DirectoryOpenNotAllowed"]).toContain(rejected.primaryIssueCode); }); it("matches the shared JSON default", () => { diff --git a/QuickShell.Raycast/src/__tests__/single-row-launch.test.ts b/QuickShell.Raycast/src/__tests__/single-row-launch.test.ts index 20944040..e0f65157 100644 --- a/QuickShell.Raycast/src/__tests__/single-row-launch.test.ts +++ b/QuickShell.Raycast/src/__tests__/single-row-launch.test.ts @@ -1,13 +1,26 @@ -import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { authorizePostLaunchEffects } from "../lib/security"; import type { StoredWorkspace, Workspace } from "../lib/schema"; describe("single-row launch skips companion and dev-server", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("suppresses companion and dev-server when include flags are false", () => { + const directory = mkdtempSync(path.join(tmpdir(), "qs-single-row-")); + dirs.push(directory); const content: Workspace = { id: "ws", name: "Demo", - directory: "\\\\wsl$\\Ubuntu\\home\\dev\\project", + directory, terminal: "wt", command: "npm test", runAsAdmin: false, diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index 5f16909b..f178d2fd 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { QuickShellStorage, createMemoryStorageAdapter } from "../lib/storage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QuickShellStorage, WRITE_LOCK_TIMEOUT_MS, createMemoryStorageAdapter } from "../lib/storage"; import { createStableId } from "../lib/ids"; import { normalizeWorkspace } from "../lib/validation"; -import { createEmptyStoredData } from "../lib/schema"; +import { BACKUP_STORAGE_KEY, createEmptyStoredData, DEFAULT_SETTINGS } from "../lib/schema"; import { createReviewToken, matchesReviewToken, setWorkspaceTrustEnabledForTests } from "../lib/security"; beforeEach(() => { @@ -361,4 +361,268 @@ describe("storage", () => { expect(loaded[0].name).toBe("Recents Renamed"); expect(loaded[0].lastUsedUtc).toBe(usedAt.toISOString()); }); + + it("serializes overlapping mutations so the later write cannot clobber the earlier one", async () => { + const storage = new QuickShellStorage(createMemoryStorageAdapter()); + const first = createWorkspace(createStableId(), "Alpha"); + const second = createWorkspace(createStableId(), "Beta"); + + await Promise.all([storage.upsertWorkspace(first), storage.upsertWorkspace(second)]); + + const loaded = await storage.getWorkspaces(); + expect(loaded).toHaveLength(2); + expect(loaded.map((workspace) => workspace.name).sort()).toEqual(["Alpha", "Beta"]); + }); + + it("serializes overlapping mutations on the same workspace so both updates survive", async () => { + const storage = new QuickShellStorage(createMemoryStorageAdapter()); + const id = createStableId(); + const workspace = createWorkspace(id, "Alpha"); + await storage.upsertWorkspace(workspace); + + const usedAt = new Date("2026-07-01T12:00:00.000Z"); + await Promise.all([storage.setFavorite(id, true), storage.markWorkspaceUsed(id, usedAt)]); + await storage.flushRecentWrites(); + + const loaded = await storage.getWorkspaces(); + expect(loaded).toHaveLength(1); + expect(loaded[0].isPinned).toBe(true); + expect(loaded[0].lastUsedUtc).toBe(usedAt.toISOString()); + }); + + it("queues a second mutation that starts while the first writer is awaiting storage I/O", async () => { + let releaseFirstWrite!: () => void; + const firstWriteGate = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + let signalFirstWriteStarted!: () => void; + const firstWriteStarted = new Promise((resolve) => { + signalFirstWriteStarted = resolve; + }); + let writeCount = 0; + const writeOrder: string[] = []; + + const base = createMemoryStorageAdapter(); + const adapter = { + getItem: (key: string) => base.getItem(key), + async setItem(key: string, value: string) { + writeCount += 1; + const id = writeCount; + writeOrder.push(`start:${id}`); + if (id === 1) { + signalFirstWriteStarted(); + await firstWriteGate; + } + await base.setItem(key, value); + writeOrder.push(`end:${id}`); + }, + }; + + const storage = new QuickShellStorage(adapter); + const first = createWorkspace(createStableId(), "Alpha"); + const second = createWorkspace(createStableId(), "Beta"); + + const firstUpsert = storage.upsertWorkspace(first); + await firstWriteStarted; + const secondUpsert = storage.upsertWorkspace(second); + releaseFirstWrite(); + await Promise.all([firstUpsert, secondUpsert]); + + // Without the lock, the second persist would start before the first ended. + expect(writeOrder).toEqual(["start:1", "end:1", "start:2", "end:2"]); + + const loaded = await storage.getWorkspaces(); + expect(loaded).toHaveLength(2); + expect(loaded.map((workspace) => workspace.name).sort()).toEqual(["Alpha", "Beta"]); + }); + + it("resetAll is a no-op when empty", async () => { + const storage = new QuickShellStorage(createMemoryStorageAdapter()); + const result = await storage.resetAll(); + expect(result.success).toBe(true); + expect(result.outcome).toBe("noop"); + expect(result.message).toMatch(/no workspaces/i); + expect(await storage.hasBackup()).toBe(false); + }); + + it("resetAll clears workspaces, keeps undo, preserves settings, and writes a durable backup", async () => { + const adapter = createMemoryStorageAdapter(); + const storage = new QuickShellStorage(adapter); + const id = createStableId(); + const workspace = createWorkspace(id, "Alpha"); + const initialSettings = { + ...DEFAULT_SETTINGS, + terminalApplication: "conhost" as const, + recentWorkspaceCount: 3, + }; + await storage.updateSettings(initialSettings); + await storage.upsertWorkspace(workspace); + + const result = await storage.resetAll(); + expect(result.success).toBe(true); + expect(result.outcome).toBe("reset"); + expect(await storage.getWorkspaces()).toHaveLength(0); + expect(await storage.getSettings()).toEqual(initialSettings); + expect(await storage.hasBackup()).toBe(true); + expect(storage.canUndo()).toBe(true); + + const backupRaw = await adapter.getItem(BACKUP_STORAGE_KEY); + expect(backupRaw).toBeTruthy(); + const backup = JSON.parse(backupRaw as string) as { workspaces: Array<{ id: string; name: string }> }; + expect(backup.workspaces).toHaveLength(1); + expect(backup.workspaces[0].id).toBe(workspace.id); + expect(backup.workspaces[0].name).toBe("Alpha"); + + await storage.undo(); + expect(await storage.getWorkspaces()).toHaveLength(1); + expect((await storage.getWorkspaces())[0].name).toBe("Alpha"); + }); + + it("restoreFromBackup recovers after a simulated extension restart", async () => { + const adapter = createMemoryStorageAdapter(); + const first = new QuickShellStorage(adapter); + const id = createStableId(); + await first.upsertWorkspace(createWorkspace(id, "Alpha")); + await first.resetAll(); + expect(await first.getWorkspaces()).toHaveLength(0); + + // New instance has no in-memory undo, but the durable backup remains. + const restarted = new QuickShellStorage(adapter); + expect(await restarted.hasBackup()).toBe(true); + const restored = await restarted.restoreFromBackup(); + expect(restored.success).toBe(true); + expect(restored.outcome).toBe("restored"); + expect(await restarted.getWorkspaces()).toHaveLength(1); + expect((await restarted.getWorkspaces())[0].name).toBe("Alpha"); + }); + + it("restoreFromBackup replaces workspaces but keeps current settings", async () => { + const adapter = createMemoryStorageAdapter(); + const first = new QuickShellStorage(adapter); + await first.upsertWorkspace(createWorkspace(createStableId(), "Alpha")); + await first.resetAll(); + + const postResetSettings = { + ...DEFAULT_SETTINGS, + terminalApplication: "conhost" as const, + recentWorkspaceCount: 7, + }; + await first.updateSettings(postResetSettings); + + const restored = await first.restoreFromBackup(); + expect(restored.outcome).toBe("restored"); + expect(await first.getWorkspaces()).toHaveLength(1); + expect(await first.getSettings()).toEqual(postResetSettings); + }); + + it("restoreFromBackup discards corrupt backup JSON so later calls can recover", async () => { + const adapter = createMemoryStorageAdapter(); + await adapter.setItem(BACKUP_STORAGE_KEY, "{not-json"); + const storage = new QuickShellStorage(adapter); + + const result = await storage.restoreFromBackup(); + expect(result.success).toBe(true); + expect(result.outcome).toBe("discarded"); + expect(result.message).toMatch(/not valid JSON/i); + expect(await storage.hasBackup()).toBe(false); + + const again = await storage.restoreFromBackup(); + expect(again.outcome).toBe("noop"); + expect(again.message).toMatch(/no workspace backup/i); + }); + + it.each([null, {}, [], { version: 1 }])("restoreFromBackup discards malformed backup %p", async (backup) => { + const adapter = createMemoryStorageAdapter(); + await adapter.setItem(BACKUP_STORAGE_KEY, JSON.stringify(backup)); + const storage = new QuickShellStorage(adapter); + + const existing = await storage.upsertWorkspace(createWorkspace(createStableId(), "KeepMe")); + + const result = await storage.restoreFromBackup(); + expect(result.success).toBe(true); + expect(result.outcome).toBe("discarded"); + expect(result.message).toMatch(/malformed/i); + expect(await storage.hasBackup()).toBe(false); + + const workspaces = await storage.getWorkspaces(); + expect(workspaces).toHaveLength(1); + expect(workspaces[0].id).toBe(existing.id); + }); + + it("restoreFromBackup does not rehydrate trust for workspaces without explicit backup security", async () => { + const adapter = createMemoryStorageAdapter(); + const first = new QuickShellStorage(adapter); + const id = createStableId(); + await first.upsertWorkspace(createWorkspace(id, "Alpha")); + await first.resetAll(); + + // Replace the backup with a syntactically valid shape that omits workspaceSecurity. + const backupRaw = (await adapter.getItem(BACKUP_STORAGE_KEY)) as string; + const backup = JSON.parse(backupRaw); + delete backup.workspaceSecurity; + await adapter.setItem(BACKUP_STORAGE_KEY, JSON.stringify(backup)); + + const restarted = new QuickShellStorage(adapter); + await restarted.restoreFromBackup(); + + const security = await restarted.getWorkspaceSecurityMap(); + expect(security[id].isTrusted).toBe(false); + }); + + it("restoreFromBackup preserves explicit trusted security from the backup", async () => { + const adapter = createMemoryStorageAdapter(); + const first = new QuickShellStorage(adapter); + const id = createStableId(); + const workspace = createWorkspace(id, "Alpha"); + await first.upsertWorkspace(workspace); + const stored = await first.getStoredWorkspace(id); + expect(stored).not.toBeNull(); + await first.grantTrust(id, createReviewToken(stored!)); + await first.resetAll(); + + const restarted = new QuickShellStorage(adapter); + await restarted.restoreFromBackup(); + + const security = await restarted.getWorkspaceSecurityMap(); + expect(security[id].isTrusted).toBe(true); + expect(security[id].revision).toBeGreaterThanOrEqual(1); + }); + + it("withWriteLock reports a timeout diagnostic when the previous writer stalls", async () => { + vi.useFakeTimers(); + try { + const base = createMemoryStorageAdapter(); + const adapter = { + getItem: base.getItem, + setItem: () => new Promise(() => {}), + }; + const storage = new QuickShellStorage(adapter); + + const workspace = createWorkspace(createStableId(), "Alpha"); + const first = storage.upsertWorkspace(workspace); + const second = storage.upsertWorkspace(createWorkspace(createStableId(), "Beta")); + + vi.advanceTimersByTime(WRITE_LOCK_TIMEOUT_MS + 1); + + await expect(second).rejects.toThrow(/lock-timeout/); + + // Let the first promise hang; it has no rejection listener, but fake timers + // mean its wait timer will not produce an unhandled rejection in this test. + first.catch(() => {}); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects nested write locks instead of deadlocking", async () => { + const storage = new QuickShellStorage(createMemoryStorageAdapter()); + type LockHost = { + withWriteLock: (operation: () => Promise) => Promise; + }; + const locked = storage as unknown as LockHost; + + await expect(locked.withWriteLock(async () => locked.withWriteLock(async () => "nested"))).rejects.toThrow( + /Nested QuickShellStorage write lock/, + ); + }); }); diff --git a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts index b8f601aa..f3e30f94 100644 --- a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts +++ b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts @@ -1,5 +1,19 @@ import { describe, expect, it } from "vitest"; -import { buildSuggestCommandArgs, pillsToSetupTasks, type SuggestionPill } from "../lib/suggest-commands"; +import { + buildSuggestCommandArgs, + pillsToSetupTasks, + splitPillsIntoSeedAndLeftover, + type SuggestionPill, +} from "../lib/suggest-commands"; + +function pill(partial: Partial & Pick): SuggestionPill { + return { + typeTitle: partial.typeTitle ?? partial.taskType, + displayTitle: partial.displayTitle ?? partial.command, + tooltip: partial.tooltip ?? partial.command, + ...partial, + }; +} describe("suggest-commands", () => { it("builds suggest CLI args with used commands", () => { @@ -49,8 +63,41 @@ describe("suggest-commands", () => { ]; expect(pillsToSetupTasks(pills)).toEqual([ - { label: "Dev server", command: "npm run dev" }, - { label: "API", command: "dotnet watch" }, + { label: "Dev server", command: "npm run dev", taskType: "frontend" }, + { label: "API", command: "dotnet watch", taskType: "api" }, + ]); + }); + + it("splits preferred setup pills into a short seed and leftover Actions pills", () => { + const pills = [ + pill({ command: "npm run build", taskType: "build", displayTitle: "Build" }), + pill({ command: "npm run dev", taskType: "frontend", displayTitle: "Dev" }), + pill({ command: "dotnet watch", taskType: "api", displayTitle: "API" }), + pill({ command: "npm test", taskType: "test", displayTitle: "Test" }), + pill({ command: "claude", taskType: "agent", displayTitle: "Agent" }), + pill({ command: "npm run lint", taskType: "none", displayTitle: "Lint" }), + ]; + + const split = splitPillsIntoSeedAndLeftover(pills, 4); + expect(split.tasks.map((task) => task.command)).toEqual([ + "npm run build", + "npm run dev", + "dotnet watch", + "npm test", ]); + expect(split.tasks[1].taskType).toBe("frontend"); + expect(split.leftoverPills.map((entry) => entry.command)).toEqual(["claude", "npm run lint"]); + }); + + it("falls back to the first pills when none are preferred setup types", () => { + const pills = [ + pill({ command: "echo one", taskType: "none", displayTitle: "One" }), + pill({ command: "echo two", taskType: "none", displayTitle: "Two" }), + pill({ command: "echo three", taskType: "none", displayTitle: "Three" }), + ]; + + const split = splitPillsIntoSeedAndLeftover(pills, 4); + expect(split.tasks.map((task) => task.command)).toEqual(["echo one", "echo two"]); + expect(split.leftoverPills.map((entry) => entry.command)).toEqual(["echo three"]); }); }); diff --git a/QuickShell.Raycast/src/__tests__/validation.test.ts b/QuickShell.Raycast/src/__tests__/validation.test.ts index d3a110c2..7328c838 100644 --- a/QuickShell.Raycast/src/__tests__/validation.test.ts +++ b/QuickShell.Raycast/src/__tests__/validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeWorkspace, validateWorkspace } from "../lib/validation"; +import { normalizeWorkspace, validateWorkspace, workspaceHasConfiguredCompanions } from "../lib/validation"; import type { Workspace } from "../lib/schema"; function sampleWorkspace(overrides: Partial = {}): Workspace { @@ -55,4 +55,23 @@ describe("validation", () => { expect(workspace.launches).toHaveLength(1); expect(workspace.launches[0].command).toBe("dotnet run"); }); + + it("detects configured companion apps for list actions", () => { + expect(workspaceHasConfiguredCompanions(sampleWorkspace())).toBe(false); + expect( + workspaceHasConfiguredCompanions( + sampleWorkspace({ + companionApps: [ + { + id: "c1", + path: "C:\\Editors\\Code.exe", + arguments: null, + openOnLaunch: false, + order: 0, + }, + ], + }), + ), + ).toBe(true); + }); }); diff --git a/QuickShell.Raycast/src/__tests__/workspace-form-state.test.ts b/QuickShell.Raycast/src/__tests__/workspace-form-state.test.ts index eea88bec..c6cd4b0f 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-form-state.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-form-state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Workspace } from "../lib/schema"; import { additionalLaunchCount, + applySuggestionPillToLaunchRows, buildWorkspaceFromFormState, filterWorkspacesForEdit, launchRowsFromSuggestions, @@ -31,7 +32,7 @@ const multiLaunchWorkspace: Workspace = { runAsAdmin: false, isEnabled: true, order: 0, - taskType: "none", + taskType: "api", }, { id: "1b", @@ -42,7 +43,7 @@ const multiLaunchWorkspace: Workspace = { runAsAdmin: false, isEnabled: true, order: 1, - taskType: "none", + taskType: "frontend", }, ], }; @@ -60,6 +61,31 @@ const defaultExtras = { repoUrl: "", }; +function launchRow( + overrides: Partial<{ + id: string; + command: string; + terminal: string; + wtProfile: string | null; + runAsAdmin: boolean; + isEnabled: boolean; + label: string; + taskType: string; + }> = {}, +) { + return { + id: "1a", + command: "dotnet run", + terminal: "wt", + wtProfile: null as string | null, + runAsAdmin: false, + isEnabled: true, + label: "API", + taskType: "none", + ...overrides, + }; +} + describe("workspace-form-state", () => { it("preserves additional launches when editing the primary launch", () => { const next = buildWorkspaceFromFormState(multiLaunchWorkspace, { @@ -71,24 +97,13 @@ describe("workspace-form-state", () => { isPinned: false, runAsAdmin: false, launches: [ - { - id: "1a", - command: "dotnet watch run", - terminal: "wt", - wtProfile: null, - runAsAdmin: false, - isEnabled: true, - label: "API", - }, - { + launchRow({ id: "1a", command: "dotnet watch run", label: "API", taskType: "api" }), + launchRow({ id: "1b", command: "npm run dev", - terminal: "wt", - wtProfile: null, - runAsAdmin: false, - isEnabled: true, label: "Web", - }, + taskType: "frontend", + }), ], ...defaultExtras, }); @@ -109,24 +124,8 @@ describe("workspace-form-state", () => { isPinned: false, runAsAdmin: false, launches: [ - { - id: "1a", - command: "dotnet run", - terminal: "wt", - wtProfile: null, - runAsAdmin: false, - isEnabled: true, - label: "API", - }, - { - id: "1b", - command: "", - terminal: "wt", - wtProfile: null, - runAsAdmin: false, - isEnabled: true, - label: "Web", - }, + launchRow({ id: "1a", command: "dotnet run", label: "API", taskType: "api" }), + launchRow({ id: "1b", command: "", label: "Web", taskType: "frontend" }), ], ...defaultExtras, }); @@ -145,24 +144,22 @@ describe("workspace-form-state", () => { isPinned: false, runAsAdmin: false, launches: [ - { + launchRow({ id: "1a", command: "dotnet run", terminal: "wt", wtProfile: "PowerShell", - runAsAdmin: false, - isEnabled: true, label: "API", - }, - { + taskType: "api", + }), + launchRow({ id: "1b", command: "", terminal: "wt", wtProfile: "Ubuntu", - runAsAdmin: false, - isEnabled: true, label: "Web", - }, + taskType: "frontend", + }), ], ...defaultExtras, }); @@ -193,15 +190,14 @@ describe("workspace-form-state", () => { isPinned: false, runAsAdmin: false, launches: [ - { + launchRow({ id: "1a", command: "dotnet run", terminal: "wt", wtProfile: "PowerShell", - runAsAdmin: false, - isEnabled: true, label: "API", - }, + taskType: "api", + }), ], ...defaultExtras, }); @@ -219,11 +215,27 @@ describe("workspace-form-state", () => { expect(state.launches[1].label).toBe("Web"); }); - it("builds launch rows from project suggestions", () => { + it("round-trips taskType through form state load and save", () => { + const state = workspaceFormStateFromWorkspace(multiLaunchWorkspace); + expect(state.launches[0].taskType).toBe("api"); + expect(state.launches[1].taskType).toBe("frontend"); + + const next = buildWorkspaceFromFormState(multiLaunchWorkspace, { + ...state, + ...defaultExtras, + companions: state.companions, + launches: state.launches, + }); + + expect(next.launches[0].taskType).toBe("api"); + expect(next.launches[1].taskType).toBe("frontend"); + }); + + it("builds launch rows from project suggestions including taskType", () => { const rows = launchRowsFromSuggestions( [ - { label: "Dev", command: "npm run dev" }, - { label: "Tests", command: "npm run test" }, + { label: "Dev", command: "npm run dev", taskType: "frontend" }, + { label: "Tests", command: "npm run test", taskType: "test" }, ], "wt", ); @@ -231,8 +243,42 @@ describe("workspace-form-state", () => { expect(rows).toHaveLength(2); expect(rows[0].command).toBe("npm run dev"); expect(rows[0].terminal).toBe("wt"); + expect(rows[0].taskType).toBe("frontend"); expect(rows[1].label).toBe("Tests"); expect(rows[1].terminal).toBe("same-as-previous"); + expect(rows[1].taskType).toBe("test"); + }); + + it("applies suggestion pills into the first empty row and preserves taskType", () => { + const filled = applySuggestionPillToLaunchRows( + [ + launchRow({ id: "empty", command: "", label: "Launch", taskType: "none" }), + launchRow({ id: "kept", command: "npm test", label: "Tests", taskType: "test" }), + ], + { + command: "claude", + taskType: "agent", + displayTitle: "Claude", + }, + ); + + expect(filled[0].command).toBe("claude"); + expect(filled[0].label).toBe("Claude"); + expect(filled[0].taskType).toBe("agent"); + expect(filled[1].command).toBe("npm test"); + }); + + it("appends a suggestion pill when every command row is filled", () => { + const appended = applySuggestionPillToLaunchRows( + [launchRow({ id: "1a", command: "npm run dev", label: "Dev", taskType: "frontend" })], + { command: "dotnet watch", taskType: "api", typeTitle: "API" }, + ); + + expect(appended).toHaveLength(2); + expect(appended[1].command).toBe("dotnet watch"); + expect(appended[1].taskType).toBe("api"); + expect(appended[1].label).toBe("API"); + expect(appended[1].terminal).toBe("same-as-previous"); }); it("defaults added launch terminal like CmdPal (default then same-as-previous)", () => { @@ -281,15 +327,13 @@ describe("workspace-form-state", () => { isPinned: false, runAsAdmin: true, launches: [ - { + launchRow({ id: "1a", command: "npm run dev", terminal: "default", - wtProfile: null, - runAsAdmin: false, - isEnabled: true, label: "Dev", - }, + taskType: "frontend", + }), ], ...defaultExtras, }); diff --git a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index 79ae562a..994f1fd8 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -1,13 +1,52 @@ +import { execFile } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_EXPORT_FILE_NAME, + buildMacTransferOsascript, + buildWindowsTransferPowerShell, + pickWorkspaceTransferJsonPath, readWorkspaceImportFile, + selectedPathFromDialogStdout, writeWorkspaceExportFile, } from "../lib/workspace-transfer-files"; +const { execFileMock } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { promisify } = require("node:util") as typeof import("node:util"); + const execFileMock = vi.fn(); + // Preserve Node's execFile promisify contract ({ stdout, stderr }) for success paths. + const withCustom = execFileMock as typeof execFileMock & Record; + withCustom[promisify.custom] = (file: unknown, args: unknown, options: unknown) => + new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFileMock( + file as string, + args as string[], + options as object, + (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => { + if (error) { + Object.assign(error, { stdout, stderr }); + reject(error); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }, + ); + }); + return { execFileMock }; +}); + +vi.mock("node:child_process", () => ({ + execFile: execFileMock, +})); + +vi.mock("../lib/platform", () => ({ + isWindowsPlatform: vi.fn(() => true), + isMacPlatform: vi.fn(() => false), +})); + describe("workspace-transfer-files", () => { const dirs: string[] = []; @@ -15,6 +54,7 @@ describe("workspace-transfer-files", () => { for (const dir of dirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } + execFileMock.mockReset(); }); it("writes and reads UTF-8 JSON round-trip", () => { @@ -28,4 +68,150 @@ describe("workspace-transfer-files", () => { expect(readFileSync(filePath, "utf8")).toBe(json); expect(readWorkspaceImportFile(filePath)).toBe(json); }); + + it("seeds Windows dialogs on the Desktop for faster first paint", () => { + expect(buildWindowsTransferPowerShell("open")).toContain("GetFolderPath('Desktop')"); + expect(buildWindowsTransferPowerShell("save")).toContain("GetFolderPath('Desktop')"); + }); + + it("writes the selected path before reactivating Raycast on Windows", () => { + const windowsOpen = buildWindowsTransferPowerShell("open"); + const writeIndex = windowsOpen.indexOf("[Console]::Out.Write($selected)"); + const activateIndex = windowsOpen.indexOf("AppActivate"); + expect(writeIndex).toBeGreaterThan(-1); + expect(activateIndex).toBeGreaterThan(-1); + expect(writeIndex).toBeLessThan(activateIndex); + expect(windowsOpen).toContain("catch [System.Runtime.InteropServices.COMException]"); + expect(windowsOpen).not.toContain("catch {}"); + }); + + it("reactivates Raycast after Windows and macOS file dialogs", () => { + const windowsOpen = buildWindowsTransferPowerShell("open"); + const windowsSave = buildWindowsTransferPowerShell("save"); + expect(windowsOpen).toContain("AppActivate"); + expect(windowsSave).toContain("AppActivate"); + + const macOpen = buildMacTransferOsascript("open"); + const macSave = buildMacTransferOsascript("save"); + expect(macOpen).toContain('tell application "Raycast" to activate'); + expect(macSave).toContain('tell application "Raycast" to activate'); + expect(macOpen).toContain("errNum is not in {-600, -1728, -10810}"); + // Activation is best-effort and must not discard a valid selection. + expect(macOpen.indexOf("end try")).toBeLessThan(macOpen.lastIndexOf('tell application "Raycast" to activate')); + expect(macOpen.indexOf('tell application "Raycast" to activate')).toBeLessThan( + macOpen.lastIndexOf("return chosenPath"), + ); + }); + + it("resolves a selected path from dialog stdout", () => { + expect(selectedPathFromDialogStdout("C:\\Users\\dev\\export.json\n")).toBe( + path.resolve("C:\\Users\\dev\\export.json"), + ); + expect(selectedPathFromDialogStdout(" ")).toBeNull(); + expect(selectedPathFromDialogStdout(undefined)).toBeNull(); + }); + + it("keeps a Windows selection when the shell fails after writing stdout", async () => { + const selected = path.join(tmpdir(), "kept-export.json"); + execFileMock.mockImplementation((( + _file: string, + argsOrOptions: unknown, + optionsOrCallback: unknown, + maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + const callback = + typeof optionsOrCallback === "function" + ? (optionsOrCallback as (error: Error | null, stdout: string, stderr: string) => void) + : maybeCallback; + const args = Array.isArray(argsOrOptions) ? argsOrOptions.map(String) : []; + const isDialog = args.some((arg) => arg.includes("SaveFileDialog") || arg.includes("OpenFileDialog")); + if (!callback) { + throw new Error("execFile mock missing callback"); + } + if (isDialog) { + const error = Object.assign(new Error("Command failed"), { code: 1 }); + callback(error, `${selected}\n`, ""); + return {} as never; + } + callback(null, "", ""); + return {} as never; + }) as unknown as typeof execFile); + + const result = await pickWorkspaceTransferJsonPath("save"); + expect(result).toBe(path.resolve(selected)); + }); + + it("falls through from missing pwsh to powershell.exe", async () => { + const selected = path.join(tmpdir(), "pwsh-fallback.json"); + const dialogShells: string[] = []; + execFileMock.mockImplementation((( + file: string, + argsOrOptions: unknown, + optionsOrCallback: unknown, + maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + const callback = + typeof optionsOrCallback === "function" + ? (optionsOrCallback as (error: Error | null, stdout: string, stderr: string) => void) + : maybeCallback; + const args = Array.isArray(argsOrOptions) ? argsOrOptions.map(String) : []; + const isDialog = args.some((arg) => arg.includes("SaveFileDialog") || arg.includes("OpenFileDialog")); + if (!callback) { + throw new Error("execFile mock missing callback"); + } + if (!isDialog) { + callback(null, "", ""); + return {} as never; + } + dialogShells.push(file); + if (file === "pwsh") { + const error = Object.assign(new Error("not found"), { code: "ENOENT" }); + callback(error, "", ""); + return {} as never; + } + callback(null, `${selected}\n`, ""); + return {} as never; + }) as unknown as typeof execFile); + + const result = await pickWorkspaceTransferJsonPath("open"); + expect(dialogShells).toEqual(["pwsh", "powershell.exe"]); + expect(result).toBe(path.resolve(selected)); + }); + + it("falls through from non-ENOENT pwsh failure to powershell.exe", async () => { + const selected = path.join(tmpdir(), "pwsh-runtime-fallback.json"); + const dialogShells: string[] = []; + execFileMock.mockImplementation((( + file: string, + argsOrOptions: unknown, + optionsOrCallback: unknown, + maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + const callback = + typeof optionsOrCallback === "function" + ? (optionsOrCallback as (error: Error | null, stdout: string, stderr: string) => void) + : maybeCallback; + const args = Array.isArray(argsOrOptions) ? argsOrOptions.map(String) : []; + const isDialog = args.some((arg) => arg.includes("SaveFileDialog") || arg.includes("OpenFileDialog")); + if (!callback) { + throw new Error("execFile mock missing callback"); + } + if (!isDialog) { + callback(null, "", ""); + return {} as never; + } + dialogShells.push(file); + if (file === "pwsh") { + const error = Object.assign(new Error("Add-Type failed"), { code: 1 }); + callback(error, "", ""); + return {} as never; + } + callback(null, `${selected}\n`, ""); + return {} as never; + }) as unknown as typeof execFile); + + const result = await pickWorkspaceTransferJsonPath("open"); + expect(dialogShells).toEqual(["pwsh", "powershell.exe"]); + expect(result).toBe(path.resolve(selected)); + }); }); diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index e89ff8cb..f1cf2eb9 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -5,7 +5,7 @@ import WorkspaceForm from "./workspace-form"; import UnsupportedPlatformView from "./unsupported-platform-view"; import { detectCompanionSeed } from "../lib/companion-detection"; import { detectDevServerUrl } from "../lib/detect-dev-server-url"; -import { buildProjectSetupSuggestions } from "../lib/project-setup-suggestion"; +import { resolveWorkspaceSetupSuggestions } from "../lib/suggest-commands"; import { discoverGitReposCached } from "../lib/git-repo-discovery"; import { searchRootsFromWorkspaces } from "../lib/git-repo-search-roots"; import { deriveAbbreviationFromName, deriveNameFromDirectory } from "../lib/directory-helpers"; @@ -26,10 +26,10 @@ type ReviewWorkspaceFormProps = { onCreated: (workspace: Workspace) => Promise; }; -/** Full seed for Review: launches, companions, remotes, project suggestions. */ -function buildWorkspaceFromRepo(directory: string, name: string, remoteUrl?: string | null): Workspace { - const suggestions = buildProjectSetupSuggestions(directory); - const rows = launchRowsFromSuggestions(suggestions); +/** Full seed for Review: launches, companions, remotes, Suggest.exe or local heuristics. */ +async function buildWorkspaceFromRepo(directory: string, name: string, remoteUrl?: string | null): Promise { + const resolved = await resolveWorkspaceSetupSuggestions(directory); + const rows = launchRowsFromSuggestions(resolved.tasks); const launchEntries = rows.length > 0 ? rows.map((row, index) => ({ @@ -41,7 +41,7 @@ function buildWorkspaceFromRepo(directory: string, name: string, remoteUrl?: str runAsAdmin: row.runAsAdmin, isEnabled: row.isEnabled, order: index, - taskType: "none" as const, + taskType: row.taskType || "none", })) : [ { @@ -124,10 +124,29 @@ function buildLightWorkspaceFromRepo(directory: string, name: string, remoteUrl? } function ReviewWorkspaceForm({ directory, name, remoteUrl, onCreated }: ReviewWorkspaceFormProps) { - const initialWorkspace = useMemo( - () => buildWorkspaceFromRepo(directory, name, remoteUrl), - [directory, name, remoteUrl], - ); + const { + data: initialWorkspace, + isLoading, + error, + } = usePromise(async () => buildWorkspaceFromRepo(directory, name, remoteUrl)); + + useLoadErrorToast(error, "Failed to prepare workspace"); + + if (error) { + return ( + + + + ); + } + + if (!initialWorkspace) { + return ( + + + + ); + } return ( @@ -235,6 +254,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true icon={Icon.Pencil} target={ { + if (directorySeedMode !== "full" || mode === "edit") { + return; + } + const directory = initialState.directory.trim(); + if (!directory) { + return; + } + const seededCommands = initialState.launches.map((launch) => launch.command.trim()).filter(Boolean); + if (seededCommands.length === 0) { + void applyDirectorySuggestions(directory); + return; + } + + let cancelled = false; + void (async () => { + const generation = ++suggestionGenerationRef.current; + const resolved = await resolveWorkspaceSetupSuggestions(directory, seededCommands); + if (cancelled || generation !== suggestionGenerationRef.current) { + return; + } + setSuggestionSource(resolved.source); + // Keep existing seed rows; expose Suggest leftovers (and any unused seed candidates) as pills. + const used = new Set(seededCommands.map((command) => command.toLowerCase())); + const leftoverFromSeed = resolved.tasks + .filter((task) => !used.has(task.command.trim().toLowerCase())) + .map((task) => ({ + command: task.command, + taskType: task.taskType?.trim() || "none", + typeTitle: task.taskType?.trim() || "Setup", + displayTitle: task.label, + tooltip: task.command, + })); + setSuggestionPills([ + ...leftoverFromSeed, + ...resolved.pills.filter((pill) => !used.has(pill.command.trim().toLowerCase())), + ]); + })(); + + return () => { + cancelled = true; + }; + // Intentionally once on mount for pre-seeded Review/Discover create flows. + }, []); + const initialValues = useMemo( () => valuesFromState(initialState, terminalChoices, draftValues), // terminalChoices intentionally omitted: useForm should keep the first selection id. @@ -281,6 +331,7 @@ export default function WorkspaceForm({ runAsAdmin: values.runAsAdmin, isEnabled: true, label: "Launch", + taskType: "none", }, ]); } @@ -313,30 +364,12 @@ export default function WorkspaceForm({ function applySuggestionPill(pill: SuggestionPill) { commandsCustomizedRef.current = true; - setLaunches((current) => { - if (current.length === 1 && !current[0].command.trim()) { - return [ - { - ...current[0], - command: pill.command, - label: pill.displayTitle || pill.typeTitle || pill.command, - }, - ]; - } - const terminal = terminalForAddedLaunch(current, "default"); - return [ - ...current, - { - id: createStableId(), - command: pill.command, - terminal: terminal.terminal, - wtProfile: terminal.wtProfile, - runAsAdmin: values.runAsAdmin, - isEnabled: true, - label: pill.displayTitle || pill.typeTitle || pill.command, - }, - ]; - }); + setLaunches((current) => + applySuggestionPillToLaunchRows(current, pill, { + runAsAdmin: values.runAsAdmin, + firstLaunchTerminal: selectedTerminal?.terminal ?? "default", + }), + ); setSuggestionPills((current) => current.filter((entry) => entry.command.trim().toLowerCase() !== pill.command.trim().toLowerCase()), ); @@ -361,6 +394,7 @@ export default function WorkspaceForm({ runAsAdmin: values.runAsAdmin, isEnabled: true, label: `Launch ${current.length + 1}`, + taskType: "none", }, ]; }); @@ -487,7 +521,8 @@ export default function WorkspaceForm({ terminal: selectedTerminal?.terminal ?? "default", wtProfile: selectedTerminal?.wtProfile ?? null, isPinned: formValues.isPinned, - runAsAdmin: formValues.runAsAdmin, + runAsAdmin: isMacPlatform() ? initialWorkspace.runAsAdmin : formValues.runAsAdmin, + // Keep elevation metadata on macOS saves so Windows re-import still elevates. launches, companions, devServerUrl: formValues.devServerUrl, @@ -547,6 +582,7 @@ export default function WorkspaceForm({ runAsAdmin: false, isEnabled: true, label: "Launch", + taskType: "none", }, ]); nameCustomizedRef.current = false; @@ -692,7 +728,7 @@ export default function WorkspaceForm({ /> ) : null} - + {!isMacPlatform() ? : null} handleCompanionExecutableChange(index, paths)} canChooseFiles canChooseDirectories={false} allowMultipleSelection={false} - info="Opens the file explorer to pick an .exe (or shortcut)." + info={ + isMacPlatform() + ? "Opens Finder to pick an .app bundle or executable." + : "Opens the file explorer to pick an .exe (or shortcut)." + } /> ) : null, )} diff --git a/QuickShell.Raycast/src/lib/git-repo-search-roots.ts b/QuickShell.Raycast/src/lib/git-repo-search-roots.ts index 772e4367..84048567 100644 --- a/QuickShell.Raycast/src/lib/git-repo-search-roots.ts +++ b/QuickShell.Raycast/src/lib/git-repo-search-roots.ts @@ -17,12 +17,23 @@ export const COMMON_ROOT_FOLDER_NAMES = [ "Documents", ] as const; +/** Extra top-level profile folders scanned on macOS only. */ +export const MAC_ROOT_FOLDER_NAMES = ["Code", "Sites", "workspace", "workspaces", "GitHub"] as const; + /** * Nested paths under the user profile only (not every drive). * Includes GitHub Desktop's default: Documents/GitHub. */ export const COMMON_PROFILE_RELATIVE_NESTED_ROOTS = [["Documents", "GitHub"]] as const; +/** Extra nested profile paths scanned on macOS only. */ +export const MAC_PROFILE_RELATIVE_NESTED_ROOTS = [ + ["Library", "Developer"], + ["Documents", "Projects"], + ["Desktop", "Projects"], + ["Desktop", "Developer"], +] as const; + export type BuildSearchRootsOptions = { includeDefaultSearchRoots?: boolean; /** When set, replaces automatic profile/drive candidate generation (tests / overrides). */ @@ -93,6 +104,15 @@ export function listDefaultRootCandidates(options: BuildSearchRootsOptions = {}) candidates.push(api.join(home, ...segments)); } + if (!useWin32Style(options)) { + for (const name of MAC_ROOT_FOLDER_NAMES) { + candidates.push(api.join(home, name)); + } + for (const segments of MAC_PROFILE_RELATIVE_NESTED_ROOTS) { + candidates.push(api.join(home, ...segments)); + } + } + if (useWin32Style(options)) { const systemRoot = normalizeDriveRoot(options.systemRoot ?? "C:\\", options); const drives = options.drives ?? listWindowsDriveRoots(); diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index f4e1b005..3b3d88f2 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -1,7 +1,7 @@ -import { createStableId } from "./ids"; +import { createStableId, isStableWorkspaceId } from "./ids"; import { migrateStoredData } from "./migration"; import type { LayoutEntry, StoredData, Workspace } from "./schema"; -import { createEmptyStoredData } from "./schema"; +import { SCHEMA_VERSION, createEmptyStoredData } from "./schema"; import { createIngressSecurity } from "./security"; type UnknownRecord = Record; @@ -78,6 +78,14 @@ export function importParsedPayload(parsed: unknown, existing?: StoredData): Imp return mergeImportedData(migrated, existing); } + // CmdPal / Core layout envelope: { version, entries: [ shortcut | { Workspace } | separator ] } + if (Array.isArray(record.entries)) { + if (typeof record.version === "number" && record.version > SCHEMA_VERSION) { + throw new Error(`Unsupported Quick Shell data version: ${record.version}`); + } + return importCmdPalLayoutEnvelope(record.entries, existing); + } + const migrated = migrateStoredData(normalizeRecordKeys(record)); if (migrated.workspaces.length === 0) { throw new Error("No workspaces found in import file."); @@ -85,6 +93,65 @@ export function importParsedPayload(parsed: unknown, existing?: StoredData): Imp return mergeImportedData(migrated, existing); } +/** + * Imports desktop CmdPal/Run layout JSON (`entries`), including flat PascalCase + * shortcuts and on-disk `{ Workspace, Security }` wrappers. Separators become layout rows. + */ +function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): ImportResult { + const workspaces: unknown[] = []; + const layoutEntries: LayoutEntry[] = []; + /** Envelope-local IDs so duplicate source IDs do not share one idRemap slot. */ + const usedEnvelopeIds = new Set(); + + for (const raw of entries) { + const entry = normalizeRecordKeys(raw); + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as UnknownRecord; + const type = typeof record.type === "string" ? record.type.trim().toLowerCase() : ""; + if (type === "separator") { + const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : null; + layoutEntries.push({ type: "separator", id: createStableId(), title }); + continue; + } + + const payload = record.workspace && typeof record.workspace === "object" ? record.workspace : record; + if (!payload || typeof payload !== "object") { + continue; + } + const workspaceRecord = normalizeRecordKeys(payload) as UnknownRecord; + const name = typeof workspaceRecord.name === "string" ? workspaceRecord.name.trim() : ""; + const directory = typeof workspaceRecord.directory === "string" ? workspaceRecord.directory.trim() : ""; + if (!name || !directory) { + continue; + } + + // Stable id ties layout rows to merge retention so skipped duplicates do not shift later rows. + // Repeated source IDs get a fresh id so each layout row remaps independently. + const rawId = typeof workspaceRecord.id === "string" ? workspaceRecord.id.trim() : ""; + let workspaceId = isStableWorkspaceId(rawId) ? rawId.toLowerCase() : createStableId(); + if (usedEnvelopeIds.has(workspaceId)) { + workspaceId = createStableId(); + } + usedEnvelopeIds.add(workspaceId); + workspaces.push({ ...workspaceRecord, id: workspaceId }); + layoutEntries.push({ type: "workspace", workspaceId }); + } + + if (workspaces.length === 0) { + throw new Error("No workspaces found in import file."); + } + + const migrated = migrateStoredData({ + version: 1, + workspaces, + layoutEntries, + settings: existing?.settings ?? createEmptyStoredData().settings, + }); + return mergeImportedData(migrated, existing); +} + function importShortcutArray(items: unknown[], existing?: StoredData): ImportResult { const normalizedItems = items .map((item) => normalizeRecordKeys(item)) @@ -142,11 +209,16 @@ function mergeImportedData(imported: StoredData, existing?: StoredData): ImportR const isReplace = base.workspaces.length === 0; const newlyImported = merged.slice(base.workspaces.length); + const remappedImportLayout = remapImportedLayout(imported.layoutEntries, idRemap); const layoutEntries = isReplace - ? remapImportedLayout(imported.layoutEntries, idRemap) + ? remappedImportLayout : [ ...(base.layoutEntries ?? []), - ...newlyImported.map((workspace) => ({ type: "workspace" as const, workspaceId: workspace.id })), + // Prefer remapped imported layout (keeps CmdPal separators). Fall back when the + // remapped layout has no workspace rows (all skipped, or separators only). + ...(remappedImportLayout.some((entry) => entry.type === "workspace") + ? remappedImportLayout + : newlyImported.map((workspace) => ({ type: "workspace" as const, workspaceId: workspace.id }))), ]; return { @@ -185,7 +257,11 @@ function remapImportedLayout(layout: LayoutEntry[] | undefined, idRemap: Map ({ + hostId: resolveMacTerminalHostId(entry.launch.terminal, settings), + entries: [entry], + })); + } + + const groups: MacLaunchEntryGroup[] = []; + const groupIndexByHost = new Map(); + let previousHostId: MacTerminalHostId | undefined; + + for (const entry of entries) { + const hostId = + entry.launch.terminal?.trim().toLowerCase() === "same-as-previous" && previousHostId + ? previousHostId + : resolveMacTerminalHostId(entry.launch.terminal, settings); + previousHostId = hostId; + const existingIndex = groupIndexByHost.get(hostId); + if (existingIndex !== undefined) { + groups[existingIndex].entries.push(entry); + continue; + } + groupIndexByHost.set(hostId, groups.length); + groups.push({ hostId, entries: [entry] }); + } + + return groups; +} + /** Directory-only: `open -a App /path`. With command: osascript do-script / iTerm write text. */ export function buildMacLaunchInvocation( directory: string, @@ -108,8 +151,55 @@ export function buildMacLaunchInvocation( }; } +/** One window with tabs for multiple entries sharing a Mac terminal host. */ +export function buildMacTabbedLaunchInvocation( + entries: LaunchPlanEntry[], + hostId: MacTerminalHostId, +): MacLaunchInvocation { + if (entries.length === 0) { + throw new Error("Mac tabbed launch requires at least one entry."); + } + if (entries.length === 1) { + return buildMacLaunchInvocation(entries[0].directory, entries[0].command, hostId); + } + + const { appName, displayName } = resolveMacAppName(hostId); + const shellCommands = entries.map((entry) => buildMacShellCommand(entry.directory, entry.command)); + + if (appName === "iTerm" || appName === "iTerm2") { + const lines = ['tell application "iTerm"', " activate", " create window with default profile"]; + lines.push(` tell current session of current window to write text ${appleScriptString(shellCommands[0])}`); + for (let index = 1; index < shellCommands.length; index += 1) { + lines.push(" tell current window"); + lines.push(" create tab with default profile"); + lines.push(` tell current session to write text ${appleScriptString(shellCommands[index])}`); + lines.push(" end tell"); + } + lines.push("end tell"); + return { + executable: "osascript", + args: ["-e", lines.join("\n")], + displayName, + }; + } + + const lines = ['tell application "Terminal"', " activate"]; + lines.push(` do script ${appleScriptString(shellCommands[0])}`); + for (let index = 1; index < shellCommands.length; index += 1) { + lines.push(` do script ${appleScriptString(shellCommands[index])} in front window`); + } + lines.push("end tell"); + return { + executable: "osascript", + args: ["-e", lines.join("\n")], + displayName, + }; +} + export function buildMacLaunchInvocations(plan: LaunchPlan, settings: QuickShellSettings): MacLaunchInvocation[] { - return plan.entries.map((entry) => buildMacLaunchInvocationForEntry(entry, settings)); + const separateWindows = settings.multiLaunchPresentation === "separateWindows"; + const groups = groupMacLaunchEntries(plan.entries, settings, separateWindows); + return groups.map((group) => buildMacTabbedLaunchInvocation(group.entries, group.hostId)); } export function buildMacLaunchInvocationForEntry( diff --git a/QuickShell.Raycast/src/lib/migration.ts b/QuickShell.Raycast/src/lib/migration.ts index 39e23bdc..f3617546 100644 --- a/QuickShell.Raycast/src/lib/migration.ts +++ b/QuickShell.Raycast/src/lib/migration.ts @@ -15,7 +15,16 @@ import { isSafeGitBranchName } from "./git-launch-gate"; type UnknownRecord = Record; -export function migrateStoredData(raw: unknown): StoredData { +type MigrateOptions = { + /** + * When false, do not default missing workspaceSecurity to trusted. Used when restoring + * from an external source (e.g. a reset-all backup) so missing security metadata cannot + * silently grant trust. + */ + defaultToTrusted?: boolean; +}; + +export function migrateStoredData(raw: unknown, options?: MigrateOptions): StoredData { if (!raw || typeof raw !== "object") { return createEmptyStoredData(); } @@ -35,6 +44,8 @@ export function migrateStoredData(raw: unknown): StoredData { const settings = migrateSettings(record.settings); + const defaultToTrusted = options?.defaultToTrusted ?? true; + const workspaceSecurity: Record = {}; const rawSecurity = record.workspaceSecurity; if (rawSecurity && typeof rawSecurity === "object") { @@ -44,14 +55,14 @@ export function migrateStoredData(raw: unknown): StoredData { } const security = value as UnknownRecord; workspaceSecurity[id] = { - isTrusted: security.isTrusted !== false, + isTrusted: defaultToTrusted ? security.isTrusted !== false : security.isTrusted === true, revision: typeof security.revision === "number" && security.revision > 0 ? security.revision : 1, }; } } for (const workspace of workspaces) { - workspaceSecurity[workspace.id] ??= { isTrusted: true, revision: 1 }; + workspaceSecurity[workspace.id] ??= { isTrusted: defaultToTrusted, revision: 1 }; } const branchTargets = migrateBranchTargets(record.branchTargets); @@ -156,11 +167,12 @@ function migrateSettings(raw: unknown): QuickShellSettings { ? record.defaultProfile.trim() : DEFAULT_SETTINGS.defaultProfile; + const { recentWorkspaceCount: rawRecentWorkspaceCount } = record; let recentWorkspaceCount = DEFAULT_SETTINGS.recentWorkspaceCount; - if (typeof record.recentWorkspaceCount === "number") { - recentWorkspaceCount = normalizeRecentCount(record.recentWorkspaceCount); - } else if (typeof record.recentWorkspaceCount === "string") { - const parsed = Number.parseInt(record.recentWorkspaceCount, 10); + if (typeof rawRecentWorkspaceCount === "number") { + recentWorkspaceCount = normalizeRecentCount(rawRecentWorkspaceCount); + } else if (typeof rawRecentWorkspaceCount === "string") { + const parsed = Number.parseInt(rawRecentWorkspaceCount, 10); if (!Number.isNaN(parsed)) { recentWorkspaceCount = normalizeRecentCount(parsed); } diff --git a/QuickShell.Raycast/src/lib/preferences.ts b/QuickShell.Raycast/src/lib/preferences.ts index 5c0aec06..91dd34bb 100644 --- a/QuickShell.Raycast/src/lib/preferences.ts +++ b/QuickShell.Raycast/src/lib/preferences.ts @@ -28,11 +28,7 @@ export function preferencesToSettings(prefs: ExtensionPreferences): QuickShellSe terminalApplication, defaultProfile, recentWorkspaceCount: recentCountFromEnabled(prefs.showRecents ?? true), - multiLaunchPresentation: isMacPlatform() - ? "separateWindows" - : (prefs.singleWindowTabs ?? true) - ? "singleWindowTabs" - : "separateWindows", + multiLaunchPresentation: (prefs.singleWindowTabs ?? true) ? "singleWindowTabs" : "separateWindows", blockDirtyBranchSwitch: prefs.blockDirtyBranchSwitch ?? DEFAULT_SETTINGS.blockDirtyBranchSwitch, }; } diff --git a/QuickShell.Raycast/src/lib/project-setup-suggestion.ts b/QuickShell.Raycast/src/lib/project-setup-suggestion.ts index 9ebd2479..ecd84348 100644 --- a/QuickShell.Raycast/src/lib/project-setup-suggestion.ts +++ b/QuickShell.Raycast/src/lib/project-setup-suggestion.ts @@ -4,6 +4,7 @@ import path from "node:path"; export type WorkspaceSetupTask = { label: string; command: string; + taskType?: string; }; const PREFERRED_SCRIPT_NAMES = ["dev", "start", "test", "build"]; diff --git a/QuickShell.Raycast/src/lib/schema.ts b/QuickShell.Raycast/src/lib/schema.ts index b73bd234..1ac79070 100644 --- a/QuickShell.Raycast/src/lib/schema.ts +++ b/QuickShell.Raycast/src/lib/schema.ts @@ -2,6 +2,9 @@ export const SCHEMA_VERSION = 1; export const STORAGE_KEY = "quickshell-data"; +/** Durable snapshot written before reset-all; survives extension restarts (unlike undo). */ +export const BACKUP_STORAGE_KEY = "quickshell-data.bak"; + export type TerminalApplication = "system" | "wt" | "conhost" | "it" | "terminal" | "iterm"; export type LaunchEntry = { @@ -107,7 +110,7 @@ export const DEFAULT_SETTINGS: QuickShellSettings = { export const DEFAULT_TERMINAL = "default"; -export const TASK_TYPES = ["none", "api", "frontend", "services", "logs", "test", "build"] as const; +export const TASK_TYPES = ["none", "api", "frontend", "services", "logs", "test", "build", "agent"] as const; export type TaskType = (typeof TASK_TYPES)[number]; diff --git a/QuickShell.Raycast/src/lib/security.ts b/QuickShell.Raycast/src/lib/security.ts index a12371f0..85c41796 100644 --- a/QuickShell.Raycast/src/lib/security.ts +++ b/QuickShell.Raycast/src/lib/security.ts @@ -175,9 +175,11 @@ export function authorize( }); } else if ( directory && - (request.kind === "terminal" || request.kind === "launchEntry" || request.kind === "grantTrust") && - isLocalDirectory(directory) && - !existsSync(directory) + (request.kind === "terminal" || + request.kind === "launchEntry" || + request.kind === "grantTrust" || + request.kind === "companion") && + isMissingRequiredDirectory(directory, request.kind) ) { issues.push({ code: "DirectoryMissing", message: "Workspace directory does not exist.", blocking: true }); } @@ -298,7 +300,7 @@ export function authorize( } else if (!directory || !isLocalDirectory(directory) || !existsSync(directory)) { issues.push({ code: "DirectoryOpenNotAllowed", - message: "Only existing rooted local drive directories can be opened.", + message: "Only existing local directories can be opened.", blocking: true, }); } @@ -328,18 +330,27 @@ export function authorize( export function authorizePostLaunchEffects( workspace: StoredWorkspace, - options?: { includeCompanion?: boolean; includeDevServer?: boolean }, + options?: { + includeCompanion?: boolean; + includeDevServer?: boolean; + /** Default openOnLaunch. Use `all` for on-demand Open Companion Apps. */ + companionSelection?: "openOnLaunch" | "all"; + }, ): AuthorizedPostLaunchEffects { const companions: AuthorizedCompanionEffect[] = []; const warnings: string[] = []; if (options?.includeCompanion ?? true) { const normalizedCompanions = normalizeCompanionApps(workspace.content); + const selected = + options?.companionSelection === "all" + ? normalizedCompanions + : normalizedCompanions.filter((entry) => entry.openOnLaunch); const effectsWorkspace = { ...workspace, content: { ...workspace.content, companionApps: normalizedCompanions }, }; - for (const companion of normalizedCompanions.filter((entry) => entry.openOnLaunch)) { + for (const companion of selected) { const authorization = authorize(effectsWorkspace, { kind: "companion", companionId: companion.id }); if ( authorization.isAllowed && @@ -465,8 +476,39 @@ function canonicalDirectory(value: string): string | null { return trimmed; } +/** Rooted local path safe to open in Explorer/Finder (drive letter or POSIX absolute). */ function isLocalDirectory(directory: string): boolean { - return /^[a-zA-Z]:[\\/]/.test(directory) && !directory.startsWith("\\\\") && !directory.includes("%"); + if (directory.includes("%") || directory.startsWith("\\\\")) { + return false; + } + // Windows drive-rooted path (C:\… or C:/…). + if (/^[a-zA-Z]:[\\/]/.test(directory)) { + return true; + } + // POSIX absolute (macOS / Linux): /Users/…, /tmp/…, etc. Reject // unc-style. + if (directory.startsWith("/") && !directory.startsWith("//")) { + return true; + } + return false; +} + +/** + * Local directories must exist for launch/trust/companion. WSL UNC is format-checked for + * terminal/launch (can still hand off to wt/wsl), but companions require the path to exist. + */ +function isMissingRequiredDirectory( + directory: string, + kind: "terminal" | "launchEntry" | "grantTrust" | "companion" | string, +): boolean { + if (!existsSync(directory)) { + if (isLocalDirectory(directory)) { + return true; + } + if (kind === "companion" && /^\\\\wsl\$/i.test(directory)) { + return true; + } + } + return false; } function resolveExecutablePath(path: string): string | null { diff --git a/QuickShell.Raycast/src/lib/storage.ts b/QuickShell.Raycast/src/lib/storage.ts index 4f189e82..ef999b3a 100644 --- a/QuickShell.Raycast/src/lib/storage.ts +++ b/QuickShell.Raycast/src/lib/storage.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import type { LaunchEntry, LayoutEntry, @@ -7,7 +8,7 @@ import type { Workspace, WorkspaceSecurityMetadata, } from "./schema"; -import { STORAGE_KEY, createEmptyStoredData } from "./schema"; +import { BACKUP_STORAGE_KEY, STORAGE_KEY, createEmptyStoredData } from "./schema"; import { createStableId, ensureStableId } from "./ids"; import { parseImportPayload, @@ -32,8 +33,18 @@ export type StorageAdapter = { setItem: (key: string, value: string) => Promise; }; +type StorageTransferResult = { + success: true; + outcome: "reset" | "restored" | "noop" | "discarded"; + message: string; +}; + +/** Tracks whether the current async context already holds the write lock. */ +const writeLockContext = new AsyncLocalStorage(); + const MAX_HISTORY_ENTRIES = 25; const RECENT_WRITE_DEBOUNCE_MS = 500; +export const WRITE_LOCK_TIMEOUT_MS = 30_000; export class QuickShellStorage { private cache: StoredData | null = null; @@ -41,6 +52,8 @@ export class QuickShellStorage { private redoHistory: StoredData[] = []; private recentWriteTimer: ReturnType | null = null; private recentWriteDirty = false; + /** Serializes load-modify-save mutations so overlapping commands cannot clobber each other. */ + private writeTail: Promise = Promise.resolve(); constructor( private readonly adapter: StorageAdapter, @@ -60,44 +73,53 @@ export class QuickShellStorage { return this.redoHistory.length > 0; } + async hasBackup(): Promise { + const raw = await this.adapter.getItem(BACKUP_STORAGE_KEY); + return typeof raw === "string" && raw.length > 0; + } + async undo(): Promise { - await this.flushRecentWrites(); - if (this.undoHistory.length === 0) { - return false; - } + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + if (this.undoHistory.length === 0) { + return false; + } - if (this.cache) { - this.redoHistory.push(this.cloneData(this.cache)); - } + if (this.cache) { + this.redoHistory.push(this.cloneData(this.cache)); + } - const previous = this.undoHistory.pop(); - if (!previous) { - return false; - } + const previous = this.undoHistory.pop(); + if (!previous) { + return false; + } - this.cache = this.preserveCurrentTrust(this.cloneData(previous), this.cache); - await this.persistCache({ recordHistory: false }); - return true; + this.cache = this.preserveCurrentTrust(this.cloneData(previous), this.cache); + await this.persistCache({ recordHistory: false }); + return true; + }); } async redo(): Promise { - await this.flushRecentWrites(); - if (this.redoHistory.length === 0) { - return false; - } + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + if (this.redoHistory.length === 0) { + return false; + } - if (this.cache) { - this.undoHistory.push(this.cloneData(this.cache)); - } + if (this.cache) { + this.undoHistory.push(this.cloneData(this.cache)); + } - const next = this.redoHistory.pop(); - if (!next) { - return false; - } + const next = this.redoHistory.pop(); + if (!next) { + return false; + } - this.cache = this.preserveCurrentTrust(this.cloneData(next), this.cache); - await this.persistCache({ recordHistory: false }); - return true; + this.cache = this.preserveCurrentTrust(this.cloneData(next), this.cache); + await this.persistCache({ recordHistory: false }); + return true; + }); } async exportJson(): Promise { @@ -110,11 +132,89 @@ export class QuickShellStorage { } async importJson(raw: string, mode: "merge" | "replace" = "merge"): Promise { - await this.flushRecentWrites(); - const existing = mode === "merge" ? await this.load() : createEmptyStoredData(); - const result = parseImportPayload(raw, existing); - await this.save(result.data, { preserveSecurity: false, allowSubmittedSecurity: true }); - return result; + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const existing = mode === "merge" ? await this.load() : createEmptyStoredData(); + const result = parseImportPayload(raw, existing); + await this.saveUnlocked(result.data, { preserveSecurity: false, allowSubmittedSecurity: true }); + return result; + }); + } + + /** + * Clears all workspaces after writing a durable backup snapshot. + * Recovery: Undo (in-session) or Restore Backup (survives restart). + */ + async resetAll(): Promise { + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + await this.ensureLoaded(); + if (this.cache!.workspaces.length === 0) { + return { success: true, outcome: "noop", message: "No workspaces to reset." }; + } + + const count = this.cache!.workspaces.length; + await this.adapter.setItem(BACKUP_STORAGE_KEY, JSON.stringify(this.cache)); + + const emptied = createEmptyStoredData(); + emptied.settings = { ...this.cache!.settings }; + await this.saveUnlocked(emptied, { preserveSecurity: false, allowSubmittedSecurity: true }); + + const itemsLabel = count === 1 ? "workspace" : "workspaces"; + return { + success: true, + outcome: "reset", + message: `Reset ${count} ${itemsLabel}. Use Undo, or Restore Backup, if you change your mind.`, + }; + }); + } + + /** Restores the durable reset-all backup into the live store. */ + async restoreFromBackup(): Promise { + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const raw = await this.adapter.getItem(BACKUP_STORAGE_KEY); + if (!raw) { + return { success: true, outcome: "noop", message: "No workspace backup found." }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + await this.adapter.setItem(BACKUP_STORAGE_KEY, ""); + return { + success: true, + outcome: "discarded", + message: "Workspace backup was not valid JSON and has been discarded.", + }; + } + + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + !Array.isArray((parsed as { workspaces?: unknown }).workspaces) + ) { + await this.adapter.setItem(BACKUP_STORAGE_KEY, ""); + return { + success: true, + outcome: "discarded", + message: "Workspace backup was malformed and has been discarded.", + }; + } + + const restored = migrateStoredData(parsed, { defaultToTrusted: false }); + // UI replaces the workspace list only; keep any settings changed after reset. + await this.ensureLoaded(); + restored.settings = { ...this.cache!.settings }; + await this.saveUnlocked(restored, { preserveSecurity: false, allowSubmittedSecurity: true }); + return { + success: true, + outcome: "restored", + message: `Restored ${restored.workspaces.length} workspace${restored.workspaces.length === 1 ? "" : "s"} from backup.`, + }; + }); } async summarizeImport(raw: string, mode: "merge" | "replace" = "merge"): Promise { @@ -122,7 +222,7 @@ export class QuickShellStorage { return summarizeImportConflicts(raw, existing); } - async save( + private async saveUnlocked( data: StoredData, options?: { recordHistory?: boolean; @@ -180,6 +280,20 @@ export class QuickShellStorage { await this.persistCache({ recordHistory: false }); } + async save( + data: StoredData, + options?: { + recordHistory?: boolean; + preserveSecurity?: boolean; + allowSubmittedSecurity?: boolean; + }, + ): Promise { + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + await this.saveUnlocked(data, options); + }); + } + async getWorkspaces(): Promise { await this.ensureLoaded(); return this.cache!.workspaces.map((workspace) => this.cloneWorkspace(workspace)); @@ -220,45 +334,49 @@ export class QuickShellStorage { workspaceId: string, reviewToken: WorkspaceReviewToken, ): Promise<"granted" | "already" | "changed" | "invalid" | "missing"> { - await this.flushRecentWrites(); - const data = await this.load(); - const workspace = data.workspaces.find((candidate) => candidate.id === workspaceId); - if (!workspace) { - return "missing"; - } - const security = data.workspaceSecurity?.[workspaceId] ?? { isTrusted: true, revision: 1 }; - const currentStored: StoredWorkspace = { content: workspace, security, revision: security.revision }; - if (security.isTrusted) { - return "already"; - } - if (!matchesReviewToken(currentStored, reviewToken)) { - return "changed"; - } - const validation = validateWorkspace(workspace); - if (!validation.ok) { - return "invalid"; - } - data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; - data.workspaceSecurity[workspaceId] = { isTrusted: true, revision: security.revision + 1 }; - await this.save(data, { preserveSecurity: false, allowSubmittedSecurity: true }); - return "granted"; + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const workspace = data.workspaces.find((candidate) => candidate.id === workspaceId); + if (!workspace) { + return "missing"; + } + const security = data.workspaceSecurity?.[workspaceId] ?? { isTrusted: true, revision: 1 }; + const currentStored: StoredWorkspace = { content: workspace, security, revision: security.revision }; + if (security.isTrusted) { + return "already"; + } + if (!matchesReviewToken(currentStored, reviewToken)) { + return "changed"; + } + const validation = validateWorkspace(workspace); + if (!validation.ok) { + return "invalid"; + } + data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; + data.workspaceSecurity[workspaceId] = { isTrusted: true, revision: security.revision + 1 }; + await this.saveUnlocked(data, { preserveSecurity: false, allowSubmittedSecurity: true }); + return "granted"; + }); } async revokeTrust(workspaceId: string): Promise<"revoked" | "already" | "missing"> { - await this.flushRecentWrites(); - const data = await this.load(); - const workspace = data.workspaces.find((candidate) => candidate.id === workspaceId); - if (!workspace) { - return "missing"; - } - const security = data.workspaceSecurity?.[workspaceId] ?? { isTrusted: true, revision: 1 }; - if (!security.isTrusted) { - return "already"; - } - data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; - data.workspaceSecurity[workspaceId] = { isTrusted: false, revision: security.revision + 1 }; - await this.save(data, { preserveSecurity: false, allowSubmittedSecurity: true }); - return "revoked"; + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const workspace = data.workspaces.find((candidate) => candidate.id === workspaceId); + if (!workspace) { + return "missing"; + } + const security = data.workspaceSecurity?.[workspaceId] ?? { isTrusted: true, revision: 1 }; + if (!security.isTrusted) { + return "already"; + } + data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; + data.workspaceSecurity[workspaceId] = { isTrusted: false, revision: security.revision + 1 }; + await this.saveUnlocked(data, { preserveSecurity: false, allowSubmittedSecurity: true }); + return "revoked"; + }); } async getSettings(): Promise { @@ -271,84 +389,90 @@ export class QuickShellStorage { } async upsertWorkspace(workspace: Workspace): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const normalized = normalizeWorkspace({ - ...workspace, - id: ensureStableId(workspace.id), - launches: workspace.launches.map((launch) => ({ - ...launch, - id: ensureStableId(launch.id), - })), - }); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const normalized = normalizeWorkspace({ + ...workspace, + id: ensureStableId(workspace.id), + launches: workspace.launches.map((launch) => ({ + ...launch, + id: ensureStableId(launch.id), + })), + }); - const validation = validateWorkspace(normalized); - if (!validation.ok) { - throw new Error(validation.message); - } + const validation = validateWorkspace(normalized); + if (!validation.ok) { + throw new Error(validation.message); + } - const index = data.workspaces.findIndex((item) => item.id === normalized.id); - if (index >= 0) { - // Form edits snapshot mount-time fields; keep storage-managed usage time authoritative. - const existing = data.workspaces[index]; - data.workspaces[index] = { - ...normalized, - lastUsedUtc: existing.lastUsedUtc ?? normalized.lastUsedUtc ?? null, - }; - } else { - const countResult = validateWorkspaceCount(data.workspaces.length + 1); - if (!countResult.ok) { - throw new Error(countResult.message); + const index = data.workspaces.findIndex((item) => item.id === normalized.id); + if (index >= 0) { + // Form edits snapshot mount-time fields; keep storage-managed usage time authoritative. + const existing = data.workspaces[index]; + data.workspaces[index] = { + ...normalized, + lastUsedUtc: existing.lastUsedUtc ?? normalized.lastUsedUtc ?? null, + }; + } else { + const countResult = validateWorkspaceCount(data.workspaces.length + 1); + if (!countResult.ok) { + throw new Error(countResult.message); + } + data.workspaces.push(normalized); + data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; + data.workspaceSecurity[normalized.id] = { isTrusted: true, revision: 1 }; + data.layoutEntries = [...(data.layoutEntries ?? []), { type: "workspace", workspaceId: normalized.id }]; } - data.workspaces.push(normalized); - data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; - data.workspaceSecurity[normalized.id] = { isTrusted: true, revision: 1 }; - data.layoutEntries = [...(data.layoutEntries ?? []), { type: "workspace", workspaceId: normalized.id }]; - } - await this.save(data, { allowSubmittedSecurity: true }); - return normalized; + await this.saveUnlocked(data, { allowSubmittedSecurity: true }); + return normalized; + }); } async deleteWorkspace(workspaceId: string): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - data.workspaces = data.workspaces.filter((workspace) => workspace.id !== workspaceId); - data.layoutEntries = (data.layoutEntries ?? []).filter( - (entry) => entry.type !== "workspace" || entry.workspaceId !== workspaceId, - ); - await this.save(data); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + data.workspaces = data.workspaces.filter((workspace) => workspace.id !== workspaceId); + data.layoutEntries = (data.layoutEntries ?? []).filter( + (entry) => entry.type !== "workspace" || entry.workspaceId !== workspaceId, + ); + await this.saveUnlocked(data); + }); } async duplicateWorkspace(workspaceId: string): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const source = data.workspaces.find((workspace) => workspace.id === workspaceId); - if (!source) { - throw new Error("Workspace not found."); - } + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const source = data.workspaces.find((workspace) => workspace.id === workspaceId); + if (!source) { + throw new Error("Workspace not found."); + } - const duplicate: Workspace = normalizeWorkspace({ - ...source, - id: createStableId(), - name: `${source.name} Copy`, - abbreviation: source.abbreviation ? `${source.abbreviation}-copy` : null, - isPinned: false, - pinOrder: null, - lastUsedUtc: null, - launches: source.launches.map((launch) => ({ - ...launch, + const duplicate: Workspace = normalizeWorkspace({ + ...source, id: createStableId(), - })), - }); + name: `${source.name} Copy`, + abbreviation: source.abbreviation ? `${source.abbreviation}-copy` : null, + isPinned: false, + pinOrder: null, + lastUsedUtc: null, + launches: source.launches.map((launch) => ({ + ...launch, + id: createStableId(), + })), + }); - const sourceSecurity = data.workspaceSecurity?.[workspaceId] ?? { isTrusted: true, revision: 1 }; - data.workspaces.push(duplicate); - data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; - data.workspaceSecurity[duplicate.id] = { isTrusted: sourceSecurity.isTrusted, revision: 1 }; - data.layoutEntries = [...(data.layoutEntries ?? []), { type: "workspace", workspaceId: duplicate.id }]; - await this.save(data, { preserveSecurity: false, allowSubmittedSecurity: true }); - return duplicate; + const sourceSecurity = data.workspaceSecurity?.[workspaceId] ?? { isTrusted: true, revision: 1 }; + data.workspaces.push(duplicate); + data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; + data.workspaceSecurity[duplicate.id] = { isTrusted: sourceSecurity.isTrusted, revision: 1 }; + data.layoutEntries = [...(data.layoutEntries ?? []), { type: "workspace", workspaceId: duplicate.id }]; + await this.saveUnlocked(data, { preserveSecurity: false, allowSubmittedSecurity: true }); + return duplicate; + }); } async getBranchTargets(): Promise> { @@ -362,31 +486,35 @@ export class QuickShellStorage { } async setBranchTarget(worktreeKey: string, branch: string): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const key = worktreeKey.trim().toLowerCase(); - const value = branch.trim(); - if (!key || !value) { - throw new Error("Worktree key and branch are required."); - } - if (!isSafeGitBranchName(value)) { - throw new Error("Invalid branch name."); - } - data.branchTargets = { ...(data.branchTargets ?? {}), [key]: value }; - await this.save(data); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const key = worktreeKey.trim().toLowerCase(); + const value = branch.trim(); + if (!key || !value) { + throw new Error("Worktree key and branch are required."); + } + if (!isSafeGitBranchName(value)) { + throw new Error("Invalid branch name."); + } + data.branchTargets = { ...(data.branchTargets ?? {}), [key]: value }; + await this.saveUnlocked(data); + }); } async clearBranchTarget(worktreeKey: string): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const key = worktreeKey.trim().toLowerCase(); - if (!data.branchTargets || !(key in data.branchTargets)) { - return; - } - const next = { ...data.branchTargets }; - delete next[key]; - data.branchTargets = next; - await this.save(data); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const key = worktreeKey.trim().toLowerCase(); + if (!data.branchTargets || !(key in data.branchTargets)) { + return; + } + const next = { ...data.branchTargets }; + delete next[key]; + data.branchTargets = next; + await this.saveUnlocked(data); + }); } async getLayoutEntries(): Promise { @@ -395,132 +523,144 @@ export class QuickShellStorage { } async insertSeparator(title?: string | null, beforeWorkspaceId?: string): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const separator: LayoutEntry = { - type: "separator", - id: createStableId(), - title: title?.trim() ? title.trim() : null, - }; - const layout = [...(data.layoutEntries ?? [])]; - const insertAt = beforeWorkspaceId - ? layout.findIndex((entry) => entry.type === "workspace" && entry.workspaceId === beforeWorkspaceId) - : -1; - if (insertAt >= 0) { - layout.splice(insertAt, 0, separator); - } else { - layout.push(separator); - } - data.layoutEntries = layout; - await this.save(data); - return separator; + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const separator: LayoutEntry = { + type: "separator", + id: createStableId(), + title: title?.trim() ? title.trim() : null, + }; + const layout = [...(data.layoutEntries ?? [])]; + const insertAt = beforeWorkspaceId + ? layout.findIndex((entry) => entry.type === "workspace" && entry.workspaceId === beforeWorkspaceId) + : -1; + if (insertAt >= 0) { + layout.splice(insertAt, 0, separator); + } else { + layout.push(separator); + } + data.layoutEntries = layout; + await this.saveUnlocked(data); + return separator; + }); } async removeLayoutEntry(entryId: string): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - data.layoutEntries = (data.layoutEntries ?? []).filter((entry) => !layoutEntryMatchesId(entry, entryId)); - await this.save(data); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + data.layoutEntries = (data.layoutEntries ?? []).filter((entry) => !layoutEntryMatchesId(entry, entryId)); + await this.saveUnlocked(data); + }); } async moveLayoutEntry(entryId: string, direction: "up" | "down"): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const layout = [...(data.layoutEntries ?? [])]; - const index = layout.findIndex((entry) => layoutEntryMatchesId(entry, entryId)); - if (index < 0) { - return; - } - const swapIndex = direction === "up" ? index - 1 : index + 1; - if (swapIndex < 0 || swapIndex >= layout.length) { - return; - } - const current = layout[index]; - layout[index] = layout[swapIndex]; - layout[swapIndex] = current; - data.layoutEntries = layout; - await this.save(data); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const layout = [...(data.layoutEntries ?? [])]; + const index = layout.findIndex((entry) => layoutEntryMatchesId(entry, entryId)); + if (index < 0) { + return; + } + const swapIndex = direction === "up" ? index - 1 : index + 1; + if (swapIndex < 0 || swapIndex >= layout.length) { + return; + } + const current = layout[index]; + layout[index] = layout[swapIndex]; + layout[swapIndex] = current; + data.layoutEntries = layout; + await this.saveUnlocked(data); + }); } async setFavorite(workspaceId: string, isPinned: boolean): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const workspace = data.workspaces.find((item) => item.id === workspaceId); - if (!workspace) { - throw new Error("Workspace not found."); - } + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const workspace = data.workspaces.find((item) => item.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found."); + } - workspace.isPinned = isPinned; - if (isPinned) { - const maxPinOrder = data.workspaces - .filter((item) => item.isPinned && item.id !== workspaceId) - .reduce((max, item) => Math.max(max, item.pinOrder ?? 0), 0); - workspace.pinOrder = maxPinOrder + 1; - } else { - workspace.pinOrder = null; - } + workspace.isPinned = isPinned; + if (isPinned) { + const maxPinOrder = data.workspaces + .filter((item) => item.isPinned && item.id !== workspaceId) + .reduce((max, item) => Math.max(max, item.pinOrder ?? 0), 0); + workspace.pinOrder = maxPinOrder + 1; + } else { + workspace.pinOrder = null; + } - await this.save(data); - return { ...workspace }; + await this.saveUnlocked(data); + return { ...workspace }; + }); } /** Returns the moved workspace, or `null` when the move is a boundary no-op. */ async moveFavorite(workspaceId: string, direction: "up" | "down" | "top" | "bottom"): Promise { - await this.flushRecentWrites(); - const data = await this.load(); - const workspace = data.workspaces.find((item) => item.id === workspaceId); - if (!workspace || !workspace.isPinned) { - throw new Error("Favorite workspace not found."); - } + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + const workspace = data.workspaces.find((item) => item.id === workspaceId); + if (!workspace || !workspace.isPinned) { + throw new Error("Favorite workspace not found."); + } - // Same order as browse favorites: null pinOrder sorts last, then name. - const favorites = getFavoriteWorkspaces(data.workspaces); - const index = favorites.findIndex((item) => item.id === workspaceId); - if (index < 0) { - throw new Error("Favorite workspace not found."); - } + // Same order as browse favorites: null pinOrder sorts last, then name. + const favorites = getFavoriteWorkspaces(data.workspaces); + const index = favorites.findIndex((item) => item.id === workspaceId); + if (index < 0) { + throw new Error("Favorite workspace not found."); + } - const targetIndex = - direction === "up" - ? index - 1 - : direction === "down" - ? index + 1 - : direction === "top" - ? 0 - : favorites.length - 1; - if (targetIndex < 0 || targetIndex >= favorites.length || targetIndex === index) { - return null; - } + const targetIndex = + direction === "up" + ? index - 1 + : direction === "down" + ? index + 1 + : direction === "top" + ? 0 + : favorites.length - 1; + if (targetIndex < 0 || targetIndex >= favorites.length || targetIndex === index) { + return null; + } - if (direction === "up" || direction === "down") { - const current = favorites[index]; - favorites[index] = favorites[targetIndex]; - favorites[targetIndex] = current; - } else { - const [item] = favorites.splice(index, 1); - favorites.splice(targetIndex, 0, item); - } + if (direction === "up" || direction === "down") { + const current = favorites[index]; + favorites[index] = favorites[targetIndex]; + favorites[targetIndex] = current; + } else { + const [item] = favorites.splice(index, 1); + favorites.splice(targetIndex, 0, item); + } - favorites.forEach((item, orderIndex) => { - item.pinOrder = orderIndex + 1; - }); + favorites.forEach((item, orderIndex) => { + item.pinOrder = orderIndex + 1; + }); - await this.save(data); - return { ...favorites[targetIndex] }; + await this.saveUnlocked(data); + return { ...favorites[targetIndex] }; + }); } async markWorkspaceUsed(workspaceId: string, usedAt = new Date()): Promise { - await this.ensureLoaded(); - const workspace = this.cache!.workspaces.find((item) => item.id === workspaceId); - if (!workspace) { - throw new Error("Workspace not found."); - } - workspace.lastUsedUtc = usedAt.toISOString(); - this.recentWriteDirty = true; - this.scheduleRecentWriteFlush(); + return this.withWriteLock(async () => { + await this.ensureLoaded(); + const workspace = this.cache!.workspaces.find((item) => item.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found."); + } + workspace.lastUsedUtc = usedAt.toISOString(); + this.recentWriteDirty = true; + this.scheduleRecentWriteFlush(); + }); } - async flushRecentWrites(): Promise { + private async flushRecentWritesUnlocked(): Promise { if (this.recentWriteTimer) { clearTimeout(this.recentWriteTimer); this.recentWriteTimer = null; @@ -532,15 +672,73 @@ export class QuickShellStorage { await this.persistCache({ recordHistory: false }); } + async flushRecentWrites(): Promise { + return this.withWriteLock(() => this.flushRecentWritesUnlocked()); + } + async updateSettings(settings: QuickShellSettings): Promise { if (this.settingsProvider) { throw new Error("Settings are managed in Raycast extension preferences."); } - await this.flushRecentWrites(); - const data = await this.load(); - data.settings = { ...settings }; - await this.save(data); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + const data = await this.load(); + data.settings = { ...settings }; + await this.saveUnlocked(data); + }); + } + + /** + * Runs `operation` exclusively against other writers. + * Nested composition must call saveUnlocked / flushRecentWritesUnlocked instead of + * public save / flushRecentWrites so concurrent callers always queue. + */ + private async withWriteLock(operation: () => Promise): Promise { + if (writeLockContext.getStore()) { + throw new Error("Nested QuickShellStorage write lock: use saveUnlocked / flushRecentWritesUnlocked."); + } + + let releaseGate!: () => void; + let releaseTimer: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const release = () => { + releaseTimer?.(); + releaseGate(); + }; + + const previous = this.writeTail; + this.writeTail = previous.then( + () => gate, + () => gate, + ); + + const timeout = new Promise((_, reject) => { + const timer = setTimeout( + () => reject(new Error(`lock-timeout: ${WRITE_LOCK_TIMEOUT_MS}ms`)), + WRITE_LOCK_TIMEOUT_MS, + ); + releaseTimer = () => clearTimeout(timer); + }); + + try { + await Promise.race([previous, timeout]); + } catch (error) { + release(); + throw error; + } + + // Lock acquired; cancel the wait timeout so a long-running operation does not cause + // an unhandled rejection when the timer eventually fires. + releaseTimer?.(); + + try { + return await writeLockContext.run(true, operation); + } finally { + release(); + } } private async ensureLoaded(): Promise { diff --git a/QuickShell.Raycast/src/lib/suggest-commands.ts b/QuickShell.Raycast/src/lib/suggest-commands.ts index b2024b3d..2919d706 100644 --- a/QuickShell.Raycast/src/lib/suggest-commands.ts +++ b/QuickShell.Raycast/src/lib/suggest-commands.ts @@ -6,6 +6,12 @@ import { buildProjectSetupSuggestions, type WorkspaceSetupTask } from "./project const execFileAsync = promisify(execFile); +/** Cap setup seed so leftover pills remain available in Actions (CmdPal-shaped). */ +export const MAX_SETUP_SEED_TASKS = 4; + +const PREFERRED_SEED_TASK_TYPES = new Set(["frontend", "api", "services", "test", "build"]); +const PREFERRED_SEED_COMMAND_HINTS = ["dev", "start", "test", "build", "watch", "run"]; + export type SuggestionPill = { command: string; taskType: string; @@ -62,11 +68,78 @@ export function pillsToSetupTasks(pills: SuggestionPill[]): WorkspaceSetupTask[] tasks.push({ label: (pill.displayTitle || pill.typeTitle || command).trim() || command, command, + taskType: pill.taskType?.trim() || "none", }); } return tasks; } +export function isPreferredSetupSeedPill(pill: SuggestionPill): boolean { + const taskType = pill.taskType?.trim().toLowerCase() ?? ""; + if (PREFERRED_SEED_TASK_TYPES.has(taskType)) { + return true; + } + + const command = pill.command?.trim().toLowerCase() ?? ""; + if (!command) { + return false; + } + + return PREFERRED_SEED_COMMAND_HINTS.some( + (hint) => + command === hint || + command.endsWith(` ${hint}`) || + command.includes(` run ${hint}`) || + command.includes(` task ${hint}`) || + command.startsWith(`${hint} `), + ); +} + +/** + * Split ranked Suggest pills into a short setup seed and leftover Actions pills. + * Preferred task types / setup-like commands are taken first, capped at MAX_SETUP_SEED_TASKS. + */ +export function splitPillsIntoSeedAndLeftover( + pills: SuggestionPill[], + maxSeed = MAX_SETUP_SEED_TASKS, +): { tasks: WorkspaceSetupTask[]; leftoverPills: SuggestionPill[] } { + const usable = pills.filter((pill) => pill.command?.trim()); + if (usable.length === 0) { + return { tasks: [], leftoverPills: [] }; + } + + const seedPills: SuggestionPill[] = []; + const leftover: SuggestionPill[] = []; + const seedCommands = new Set(); + + for (const pill of usable) { + const key = pill.command.trim().toLowerCase(); + if (seedPills.length < maxSeed && isPreferredSetupSeedPill(pill) && !seedCommands.has(key)) { + seedPills.push(pill); + seedCommands.add(key); + continue; + } + leftover.push(pill); + } + + if (seedPills.length === 0) { + const take = Math.min(Math.max(1, Math.min(2, maxSeed)), usable.length); + for (let index = 0; index < take; index += 1) { + seedPills.push(usable[index]); + seedCommands.add(usable[index].command.trim().toLowerCase()); + } + return { + tasks: pillsToSetupTasks(seedPills), + leftoverPills: usable.filter((pill) => !seedCommands.has(pill.command.trim().toLowerCase())), + }; + } + + return { + tasks: pillsToSetupTasks(seedPills), + leftoverPills: leftover.filter((pill) => !seedCommands.has(pill.command.trim().toLowerCase())), + }; +} + export async function fetchSuggestionPills( directory: string, usedCommands: string[], @@ -105,13 +178,22 @@ export async function resolveWorkspaceSetupSuggestions( const response = await fetchSuggestionPills(trimmed, usedCommands, generation); if (response && response.pills.length > 0) { + const split = splitPillsIntoSeedAndLeftover(response.pills); return { source: "suggest", - tasks: pillsToSetupTasks(response.pills), - pills: response.pills, + tasks: split.tasks, + pills: split.leftoverPills, }; } const tasks = buildProjectSetupSuggestions(trimmed); - return { source: "local", tasks, pills: [] }; + const asPills: SuggestionPill[] = tasks.map((task) => ({ + command: task.command, + taskType: task.taskType?.trim() || "none", + typeTitle: task.taskType?.trim() || "Setup", + displayTitle: task.label, + tooltip: task.command, + })); + const split = splitPillsIntoSeedAndLeftover(asPills); + return { source: "local", tasks: split.tasks, pills: split.leftoverPills }; } diff --git a/QuickShell.Raycast/src/lib/terminal-options.ts b/QuickShell.Raycast/src/lib/terminal-options.ts index bec4cd1c..648ef260 100644 --- a/QuickShell.Raycast/src/lib/terminal-options.ts +++ b/QuickShell.Raycast/src/lib/terminal-options.ts @@ -123,8 +123,7 @@ export function settingsSummary(settings: QuickShellSettings): string { TERMINAL_APPLICATION_CHOICES_MAC.find((choice) => choice.id === settings.terminalApplication)?.title ?? settings.terminalApplication; const profile = settings.defaultProfile === "__default__" ? "default profile" : settings.defaultProfile; - const multiLaunch = - isMacPlatform() || settings.multiLaunchPresentation === "separateWindows" ? "separate windows" : "tabs"; + const multiLaunch = settings.multiLaunchPresentation === "separateWindows" ? "separate windows" : "tabs"; const dirtyGate = settings.blockDirtyBranchSwitch ? "block dirty switch" : "allow dirty switch"; return `${app} • ${profile} • ${multiLaunch} • ${dirtyGate}`; } diff --git a/QuickShell.Raycast/src/lib/validation.ts b/QuickShell.Raycast/src/lib/validation.ts index acd278a1..a5c4d41b 100644 --- a/QuickShell.Raycast/src/lib/validation.ts +++ b/QuickShell.Raycast/src/lib/validation.ts @@ -226,6 +226,10 @@ export function getOpenOnLaunchCompanions(workspace: Workspace): CompanionAppEnt return normalizeCompanionApps(workspace).filter((entry) => entry.openOnLaunch); } +export function workspaceHasConfiguredCompanions(workspace: Workspace): boolean { + return normalizeCompanionApps(workspace).length > 0; +} + export function normalizeLaunches(launches: LaunchEntry[], workspace: Workspace): LaunchEntry[] { if (launches.length === 0) { return [ diff --git a/QuickShell.Raycast/src/lib/workspace-form-state.ts b/QuickShell.Raycast/src/lib/workspace-form-state.ts index fcab67df..ae18455d 100644 --- a/QuickShell.Raycast/src/lib/workspace-form-state.ts +++ b/QuickShell.Raycast/src/lib/workspace-form-state.ts @@ -17,6 +17,7 @@ export type LaunchFormRow = { runAsAdmin: boolean; isEnabled: boolean; label: string; + taskType: string; }; export type CompanionFormRow = { @@ -89,7 +90,7 @@ export function buildWorkspaceFromFormState(initialWorkspace: Workspace, state: runAsAdmin: usesSharedLaunchControls(state) ? state.runAsAdmin : row.runAsAdmin || state.runAsAdmin, isEnabled: row.isEnabled, order: index, - taskType: "none", + taskType: row.taskType?.trim() || "none", })); const primary = launches.find((entry) => entry.isEnabled) ?? launches[0]; @@ -136,6 +137,7 @@ export function workspaceFormStateFromWorkspace(workspace: Workspace): Workspace runAsAdmin: launch.runAsAdmin, isEnabled: launch.isEnabled, label: launch.label, + taskType: launch.taskType?.trim() || "none", })) : [ { @@ -146,6 +148,7 @@ export function workspaceFormStateFromWorkspace(workspace: Workspace): Workspace runAsAdmin: workspace.runAsAdmin, isEnabled: true, label: workspace.name || "Launch", + taskType: "none", }, ]; @@ -179,7 +182,7 @@ export function workspaceFormStateFromWorkspace(workspace: Workspace): Workspace } export function launchRowsFromSuggestions( - suggestions: Array<{ label: string; command: string }>, + suggestions: Array<{ label: string; command: string; taskType?: string }>, terminal = "default", ): LaunchFormRow[] { return suggestions.map((suggestion, index) => ({ @@ -191,9 +194,57 @@ export function launchRowsFromSuggestions( runAsAdmin: false, isEnabled: true, label: suggestion.label, + taskType: suggestion.taskType?.trim() || "none", })); } +/** + * Apply a suggestion pill like Core LaunchRowListEditor.ApplyPill: + * fill the first empty command row, otherwise append. + */ +export function applySuggestionPillToLaunchRows( + rows: LaunchFormRow[], + pill: { command: string; taskType: string; displayTitle?: string; typeTitle?: string }, + options?: { runAsAdmin?: boolean; firstLaunchTerminal?: string }, +): LaunchFormRow[] { + const command = pill.command.trim(); + if (!command) { + return rows; + } + + const label = (pill.displayTitle || pill.typeTitle || command).trim() || command; + const taskType = pill.taskType?.trim() || "none"; + const emptyIndex = rows.findIndex((row) => !row.command.trim()); + if (emptyIndex >= 0) { + return rows.map((row, index) => + index === emptyIndex + ? { + ...row, + command, + label, + taskType, + isEnabled: true, + } + : row, + ); + } + + const terminal = terminalForAddedLaunch(rows, options?.firstLaunchTerminal ?? "default"); + return [ + ...rows, + { + id: createStableId(), + command, + terminal: terminal.terminal, + wtProfile: terminal.wtProfile, + runAsAdmin: options?.runAsAdmin ?? false, + isEnabled: true, + label, + taskType, + }, + ]; +} + /** * Default terminal for Add command / suggestion-pill append. * First real launch → default (or caller fallback); later → same-as-previous. diff --git a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts index 0b2ad009..9fc2a3fa 100644 --- a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts +++ b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts @@ -1,17 +1,23 @@ -import { execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { promisify } from "node:util"; import { isMacPlatform, isWindowsPlatform } from "./platform"; export const DEFAULT_EXPORT_FILE_NAME = "quickshell-workspaces.json"; type DialogKind = "save" | "open"; +const execFileAsync = promisify(execFile); + /** * Platform save/open dialogs (Raycast has no native save panel). * Returns an absolute path, or null when the user cancels / unsupported. + * + * Async so the Raycast UI can show a loading toast while PowerShell/osascript starts + * (WinForms file dialogs pay a cold-start cost on Windows). */ -export function pickWorkspaceTransferJsonPath(kind: DialogKind): string | null { +export async function pickWorkspaceTransferJsonPath(kind: DialogKind): Promise { if (isWindowsPlatform()) { return pickWindowsTransferJsonPath(kind); } @@ -21,11 +27,12 @@ export function pickWorkspaceTransferJsonPath(kind: DialogKind): string | null { return null; } -function pickWindowsTransferJsonPath(kind: DialogKind): string | null { - const script = +/** Builds the PowerShell script for Windows file dialogs. Exported for unit tests. */ +export function buildWindowsTransferPowerShell(kind: DialogKind): string { + const initialDirectory = "[Environment]::GetFolderPath('Desktop')"; + const dialogSetup = kind === "save" ? [ - "Add-Type -AssemblyName System.Windows.Forms", "$d = New-Object System.Windows.Forms.SaveFileDialog", "$d.Title = 'Export Quick Shell workspaces'", "$d.Filter = 'JSON files (*.json)|*.json|All files (*.*)|*.*'", @@ -33,55 +40,169 @@ function pickWindowsTransferJsonPath(kind: DialogKind): string | null { "$d.DefaultExt = 'json'", "$d.AddExtension = $true", "$d.OverwritePrompt = $true", - "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }", - ].join("; ") + `$d.InitialDirectory = ${initialDirectory}`, + ] : [ - "Add-Type -AssemblyName System.Windows.Forms", "$d = New-Object System.Windows.Forms.OpenFileDialog", "$d.Title = 'Import Quick Shell workspaces'", "$d.Filter = 'JSON files (*.json)|*.json|All files (*.*)|*.*'", "$d.CheckFileExists = $true", "$d.Multiselect = $false", - "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }", - ].join("; "); + `$d.InitialDirectory = ${initialDirectory}`, + ]; + + // WinForms dialogs steal foreground focus; restore Raycast after writing the path so a + // focus-restore failure cannot discard a valid selection (Node can also read error.stdout). + return [ + "Add-Type -AssemblyName System.Windows.Forms", + ...dialogSetup, + "$selected = $null", + "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $selected = $d.FileName }", + "if ($selected) { [Console]::Out.Write($selected) }", + reactivateRaycastPowerShellSnippet(), + ].join("; "); +} + +/** Best-effort: restore Raycast after an external dialog. Exported for unit tests. */ +export function reactivateRaycastPowerShellSnippet(): string { + // Keep this free of nested Add-Type quoting; AppActivate by PID is enough to unminimize. + // Only swallow expected process/COM failures — unexpected errors should surface. + return [ + "try {", + " $ray = Get-Process -Name Raycast -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } | Select-Object -First 1;", + " if ($ray) { $null = (New-Object -ComObject WScript.Shell).AppActivate($ray.Id) }", + "} catch [System.Runtime.InteropServices.COMException] {", + "} catch [System.Management.Automation.MethodInvocationException] {", + "}", + ].join(" "); +} + +/** Prefer a trimmed stdout path when the dialog shell exits non-zero after writing it. */ +export function selectedPathFromDialogStdout(stdout: string | null | undefined): string | null { + const selected = (stdout ?? "").trim(); + return selected.length > 0 ? path.resolve(selected) : null; +} + +async function pickWindowsTransferJsonPath(kind: DialogKind): Promise { + const script = buildWindowsTransferPowerShell(kind); + const shells = ["pwsh", "powershell.exe"]; + + for (const shell of shells) { + try { + const { stdout } = await execFileAsync( + shell, + ["-NoProfile", "-NoLogo", "-NonInteractive", "-STA", "-Command", script], + { + encoding: "utf8", + windowsHide: true, + timeout: 120_000, + maxBuffer: 1024 * 1024, + }, + ); + // Dialog script already tries AppActivate; repeat from Node in case focus raced. + await reactivateRaycastWindow(); + return selectedPathFromDialogStdout(stdout); + } catch (error) { + await reactivateRaycastWindow(); + // Path is written before AppActivate; keep a valid selection if the shell still failed. + const fromStdout = selectedPathFromDialogStdout((error as { stdout?: string } | undefined)?.stdout); + if (fromStdout) { + return fromStdout; + } + // Any thrown pwsh error with no path (ENOENT or dialog/runtime failure) should fall + // through to Windows PowerShell 5.1. Cancel is empty stdout on success above, or empty + // stdout on a thrown error here — both should try the next shell before giving up. + if (shell === "powershell.exe") { + console.error("Windows transfer dialog script failed:", error); + return null; + } + } + } + return null; +} + +/** Best-effort foreground restore after WinForms/osascript dialogs. */ +export async function reactivateRaycastWindow(): Promise { try { - const output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-STA", "-Command", script], { - encoding: "utf8", - windowsHide: true, - timeout: 120_000, - }); - const selected = output.trim(); - return selected.length > 0 ? path.resolve(selected) : null; + if (isWindowsPlatform()) { + await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NoLogo", "-NonInteractive", "-Command", reactivateRaycastPowerShellSnippet()], + { windowsHide: true, timeout: 5_000 }, + ); + return; + } + if (isMacPlatform()) { + await execFileAsync("osascript", ["-e", 'tell application "Raycast" to activate'], { + timeout: 5_000, + }); + } } catch { - return null; + // Focus restore is best-effort; never fail the transfer path. } } +/** + * AppleScript error numbers treated as expected when activating Raycast + * (-600 app not running, -1728 object not found, -10810 connection invalid). + * Unexpected numbers are logged via `log` but must not discard chosenPath. + */ +const MAC_ACTIVATE_EXPECTED_ERROR_NUMBERS = "-600, -1728, -10810"; + /** Exported for unit tests. */ export function buildMacTransferOsascript(kind: DialogKind): string { + const activateBlock = [ + "try", + ' tell application "Raycast" to activate', + "on error errMsg number errNum", + ` if errNum is not in {${MAC_ACTIVATE_EXPECTED_ERROR_NUMBERS}} then`, + ' log ("Raycast activate unexpected error " & errNum & ": " & errMsg)', + " end if", + "end try", + ]; + if (kind === "save") { return [ `set defaultName to "${DEFAULT_EXPORT_FILE_NAME}"`, - 'set chosenFile to choose file name with prompt "Export Quick Shell workspaces" default name defaultName', - "return POSIX path of chosenFile", + "try", + ' set chosenFile to choose file name with prompt "Export Quick Shell workspaces" default name defaultName', + " set chosenPath to POSIX path of chosenFile", + "on error", + ' set chosenPath to ""', + "end try", + // Focus restore is best-effort and must not discard a valid selection. + ...activateBlock, + "return chosenPath", ].join("\n"); } return [ - 'set chosenFile to choose file with prompt "Import Quick Shell workspaces" of type {"public.json", "json"}', - "return POSIX path of chosenFile", + "try", + ' set chosenFile to choose file with prompt "Import Quick Shell workspaces" of type {"public.json", "json"}', + " set chosenPath to POSIX path of chosenFile", + "on error", + ' set chosenPath to ""', + "end try", + ...activateBlock, + "return chosenPath", ].join("\n"); } -function pickMacTransferJsonPath(kind: DialogKind): string | null { +async function pickMacTransferJsonPath(kind: DialogKind): Promise { try { - const output = execFileSync("osascript", ["-e", buildMacTransferOsascript(kind)], { + const { stdout } = await execFileAsync("osascript", ["-e", buildMacTransferOsascript(kind)], { encoding: "utf8", timeout: 120_000, }); - const selected = output.trim(); - return selected.length > 0 ? path.resolve(selected) : null; - } catch { + await reactivateRaycastWindow(); + return selectedPathFromDialogStdout(stdout); + } catch (error) { + await reactivateRaycastWindow(); + const fromStdout = selectedPathFromDialogStdout((error as { stdout?: string } | undefined)?.stdout); + if (fromStdout) { + return fromStdout; + } + console.error("macOS transfer dialog script failed:", error); return null; } } diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index 2e37555a..0b5d2d81 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -33,6 +33,7 @@ import { } from "./lib/failure-feedback"; import { executeWorkspaceLaunch } from "./lib/launch-executor"; import type { LaunchDiagnosticsReport } from "./lib/launch-diagnostics"; +import { runPostLaunchActions } from "./lib/post-launch-actions"; import { raycastExec } from "./lib/raycast-exec"; import { buildBrowseSections, buildSearchResults, getMostRecentlyUsedWorkspaces } from "./lib/ranking"; import { getQuickShellStorage, workspaceSubtitle } from "./lib/raycast-storage"; @@ -53,7 +54,7 @@ import { resolveOpenWorkspaceSearchSeed, type OpenWorkspaceLaunchContext, } from "./lib/launch-context"; -import { isSupportedPlatform } from "./lib/platform"; +import { isMacPlatform, isSupportedPlatform } from "./lib/platform"; import { dualPlatformShortcut } from "./lib/shortcuts"; import { useLoadErrorToast } from "./lib/use-load-error-toast"; import { discoverWorkspaceTerminalChoices, invalidateTerminalCatalogCache } from "./lib/terminal-catalog"; @@ -68,6 +69,7 @@ import { matchesReviewToken, } from "./lib/security"; import { evaluateGitLaunchGate, resolveWorktreeKey } from "./lib/git-launch-gate"; +import { workspaceHasConfiguredCompanions } from "./lib/validation"; type LoadedData = { workspaces: Workspace[]; @@ -78,6 +80,7 @@ type LoadedData = { healthIndex: WorkspaceHealthIndex; canUndo: boolean; canRedo: boolean; + hasBackup: boolean; }; type WorkspaceRow = { @@ -122,11 +125,12 @@ export default function OpenWorkspaceCommand({ error, revalidate, } = usePromise(async (): Promise> => { - const [workspaces, settings, layoutEntries, branchTargets] = await Promise.all([ + const [workspaces, settings, layoutEntries, branchTargets, hasBackup] = await Promise.all([ storage.getWorkspaces(), storage.getSettings(), storage.getLayoutEntries(), storage.getBranchTargets(), + storage.hasBackup(), ]); const securityById = isWorkspaceTrustEnabled() @@ -141,6 +145,7 @@ export default function OpenWorkspaceCommand({ securityById, canUndo: storage.canUndo(), canRedo: storage.canRedo(), + hasBackup, }; }, []); @@ -458,6 +463,50 @@ export default function OpenWorkspaceCommand({ } } + async function handleOpenCompanions(workspace: Workspace) { + try { + const stored = await storage.getStoredWorkspace(workspace.id); + if (!stored) { + await showToast({ style: Toast.Style.Failure, title: "Workspace not found" }); + return; + } + + const authorizedEffects = authorizePostLaunchEffects(stored, { + includeCompanion: true, + includeDevServer: false, + companionSelection: "all", + }); + if (authorizedEffects.plan.companions.length === 0) { + await showToast({ + style: Toast.Style.Failure, + title: "Companion apps blocked", + message: + authorizedEffects.warnings[0] ?? "Trust this workspace and use a valid local folder with a companion app.", + }); + return; + } + + const result = await runPostLaunchActions(authorizedEffects.plan, { phase: "companions" }); + const warnings = [...authorizedEffects.warnings, ...result.warnings]; + if (!result.companionOpened) { + await showToast({ + style: Toast.Style.Failure, + title: "Companion apps failed", + message: warnings[0] ?? "Could not open companion apps.", + }); + return; + } + + await showToast({ + style: Toast.Style.Success, + title: "Companion apps opened", + message: warnings.length > 0 ? warnings.join(" ") : workspace.name, + }); + } catch (companionError) { + await showStorageFailure("Open companion apps", companionError); + } + } + async function handleOpenUrl(workspace: Workspace, kind: "repo" | "dev") { const stored = await storage.getStoredWorkspace(workspace.id); const url = kind === "repo" ? stored?.content.repoUrl : stored?.content.devServerUrl; @@ -527,11 +576,17 @@ export default function OpenWorkspaceCommand({ } async function handleExport() { + const loading = await showToast({ + style: Toast.Style.Animated, + title: "Opening export dialog…", + message: "Windows file dialogs need a short PowerShell startup.", + }); try { - const filePath = pickWorkspaceTransferJsonPath("save"); + const filePath = await pickWorkspaceTransferJsonPath("save"); if (!filePath) { return; } + loading.title = "Exporting…"; const json = await storage.exportJson(); writeWorkspaceExportFile(filePath, json); await showToast({ @@ -547,15 +602,23 @@ export default function OpenWorkspaceCommand({ }); } catch (exportError) { await showStorageFailure("Export workspaces", exportError); + } finally { + loading.hide(); } } async function handleImportFromFile() { + const loading = await showToast({ + style: Toast.Style.Animated, + title: "Opening import dialog…", + message: "Windows file dialogs need a short PowerShell startup.", + }); try { - const filePath = pickWorkspaceTransferJsonPath("open"); + const filePath = await pickWorkspaceTransferJsonPath("open"); if (!filePath) { return; } + loading.title = "Importing…"; const trimmed = readWorkspaceImportFile(filePath).trim(); if (!trimmed) { await showToast({ @@ -588,6 +651,72 @@ export default function OpenWorkspaceCommand({ }); } catch (importError) { await showStorageFailure("Import workspaces", importError); + } finally { + loading.hide(); + } + } + + async function handleResetAll() { + if (!data) { + await showToast({ style: Toast.Style.Failure, title: "Still loading workspaces" }); + return; + } + + const count = data.workspaces.length; + const itemsLabel = count === 1 ? "workspace" : "workspaces"; + const countLine = count === 0 ? `No ${itemsLabel} are saved.` : `This will delete all ${count} ${itemsLabel}.`; + const confirmed = await confirmAlert({ + title: "Reset all workspaces?", + message: `${countLine} A backup is saved first. You can Undo or use Restore Backup afterward.`, + primaryAction: { title: "Reset All", style: Alert.ActionStyle.Destructive }, + dismissAction: { title: "Cancel" }, + }); + if (!confirmed) { + return; + } + + try { + const result = await storage.resetAll(); + await revalidate(); + const isNoop = result.outcome === "noop"; + await showToast({ + // No-op is terminal; Animated would leave a forever spinner. + style: Toast.Style.Success, + title: isNoop ? "Nothing to reset" : "Workspaces reset", + message: result.message, + }); + } catch (resetError) { + await showStorageFailure("Reset workspaces", resetError); + } + } + + async function handleRestoreBackup() { + const confirmed = await confirmAlert({ + title: "Restore workspace backup?", + message: "Replace the current workspace list with the last reset backup.", + primaryAction: { title: "Restore Backup", style: Alert.ActionStyle.Destructive }, + dismissAction: { title: "Cancel" }, + }); + if (!confirmed) { + return; + } + + try { + const result = await storage.restoreFromBackup(); + await revalidate(); + const title = + result.outcome === "restored" + ? "Backup restored" + : result.outcome === "discarded" + ? "Backup discarded" + : "No backup to restore"; + await showToast({ + style: result.outcome === "restored" ? Toast.Style.Success : Toast.Style.Failure, + title, + message: result.message, + }); + } catch (restoreError) { + await showStorageFailure("Restore backup", restoreError); } } @@ -760,6 +889,20 @@ export default function OpenWorkspaceCommand({ + + {data?.hasBackup ? ( + + ) : null} ); @@ -932,7 +1075,7 @@ export default function OpenWorkspaceCommand({ accessories.push({ icon: Icon.Star, tooltip: "Favorite" }); } - const wantsAdmin = workspace.runAsAdmin || launch?.runAsAdmin; + const wantsAdmin = !isMacPlatform() && (workspace.runAsAdmin || launch?.runAsAdmin); return ( handleOpen(workspace, launch, { runAsStandard: true })} /> ) : null} - {wantsAdmin ? null : ( + {!isMacPlatform() && !wantsAdmin ? ( handleOpen(workspace, launch, { runAsAdmin: true })} /> - )} + ) : null} handleOpenFolder(workspace)} /> + {workspaceHasConfiguredCompanions(workspace) ? ( + handleOpenCompanions(workspace)} + /> + ) : null} {workspace.repoUrl ? ( handleOpenUrl(workspace, "repo")} /> ) : null} diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index 3cd7fbba..82d0f52b 100644 --- a/docs/architecture/hosts.md +++ b/docs/architecture/hosts.md @@ -6,17 +6,17 @@ All hosts retain workspace IDs rather than trust-bearing snapshots and resolve c ## Matrix -| Concern | CmdPal (`QuickShell/`) | PowerToys Run (`QuickShell.Run/`) | Raycast (`QuickShell.Raycast/`) | -|---------|------------------------|-----------------------------------|----------------------------------| -| **Runtime** | CmdPal extension host, MSIX | Wox plugin in PowerToys | Raycast extension (Node) | -| **Business logic** | **QuickShell.Core** project ref | **QuickShell.Core** project ref | **TypeScript reimplementation** | -| **Workspace store** | `%LOCALAPPDATA%\QuickShell\shortcuts.json` | **Same file** | Raycast `STORAGE_KEY` blob | -| **Settings** | `settings.json` via settings manager | **Same JSON** via `QuickShellSettingsReader` | Stored in Raycast data + prefs | -| **Launch** | `ShortcutLaunchExecutor` | Same | `launch-executor.ts` + `windows-launch.ts` | -| **Pills** | Adaptive Card / form | `RunLaunchSuggestionPanel` | `QuickShell.Suggest.exe` | -| **Edit UI** | Adaptive Card forms | WPF `ShortcutWorkspaceEditorWindow` | React form components | -| **Action keyword** | Extension name + fallback | **`qs`** (+ global activation phrases) | Raycast commands | -| **Package** | Store / WinGet CmdPal packages | Bundled with full WinGet / GH setup | Raycast store / sideload | +| Concern | CmdPal (`QuickShell/`) | PowerToys Run (`QuickShell.Run/`) | Raycast (`QuickShell.Raycast/`) | +| ------------------- | ------------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **Runtime** | CmdPal extension host, MSIX | Wox plugin in PowerToys | Raycast extension (Node) | +| **Business logic** | **QuickShell.Core** project ref | **QuickShell.Core** project ref | **TypeScript reimplementation** | +| **Workspace store** | `%LOCALAPPDATA%\QuickShell\shortcuts.json` | **Same file** | Raycast `STORAGE_KEY` (`quickshell-data`) + reset backup `BACKUP_STORAGE_KEY` (`quickshell-data.bak`) | +| **Settings** | `settings.json` via settings manager | **Same JSON** via `QuickShellSettingsReader` | Stored in Raycast data + prefs | +| **Launch** | `ShortcutLaunchExecutor` | Same | `launch-executor.ts` + `windows-launch.ts` | +| **Pills** | Adaptive Card / form | `RunLaunchSuggestionPanel` | `QuickShell.Suggest.exe` | +| **Edit UI** | Adaptive Card forms | WPF `ShortcutWorkspaceEditorWindow` | React form components | +| **Action keyword** | Extension name + fallback | **`qs`** (+ global activation phrases) | Raycast commands | +| **Package** | Store / WinGet CmdPal packages | Bundled with full WinGet / GH setup | Raycast Store only (no sideload packages) | Desktop CmdPal + Run **share** Core data files. Raycast is **parallel** unless the user imports or exports JSON. @@ -30,13 +30,13 @@ Strengths: full Adaptive Card UX, deep links, fallback, status pages, hover acti Entry: `QuickShell.Run/Main.cs` (`IPlugin`, context menu, settings provider, reloadable). -- Loads `ShortcutRepository` + `QuickShellSettingsReader` -- Preloads shortcuts async -- Query: action keyword `qs` and optional global activation via `RunGlobalQuery` (phrases like “quick shell”) -- Scoring: `RunQueryScoring` -- Launch: same `ShortcutLaunchExecutor` -- Repair/missing folder UX via `ShortcutHealth` -- Settings UI in WPF; editor window for create/edit +- Loads `ShortcutRepository` + `QuickShellSettingsReader` +- Preloads shortcuts async +- Query: action keyword `qs` and optional global activation via `RunGlobalQuery` (phrases like “quick shell”) +- Scoring: `RunQueryScoring` +- Launch: same `ShortcutLaunchExecutor` +- Repair/missing folder UX via `ShortcutHealth` +- Settings UI in WPF; editor window for create/edit Does **not** use CmdPal Adaptive Cards or `CommandRouter` deep links. @@ -44,7 +44,7 @@ Does **not** use CmdPal Adaptive Cards or `CommandRouter` deep links. Structure: -``` +```text src/ open-workspace.tsx, create/edit, discover, settings lib/ storage, schema, launch-*, health, search, suggest-commands, … @@ -53,18 +53,20 @@ src/ Important libs: -| Lib | Desktop analogue | -|-----|------------------| -| `storage.ts` | `ShortcutRepository` (+ undo) | -| `schema.ts` / `migration.ts` | layout + version | -| `windows-launch.ts` + `launch-grouping.ts` | `TerminalLauncher` + grouping | -| `launch-executor.ts` | `ShortcutLaunchExecutor` | -| `post-launch-actions.ts` | companion + dev server after terminals | -| `workspace-health.ts` | subset of health | -| `suggest-commands.ts` | shells out to Core Suggest CLI | -| `settings.ts` | settings prefs | +| Lib | Desktop analogue | +| ------------------------------------------ | ------------------------------------------------------------- | +| `storage.ts` | `ShortcutRepository` (+ undo, reset-all, write serialization) | +| `schema.ts` / `migration.ts` | layout + version (`STORAGE_KEY`, `BACKUP_STORAGE_KEY`) | +| `windows-launch.ts` + `launch-grouping.ts` | `TerminalLauncher` + grouping | +| `launch-executor.ts` | `ShortcutLaunchExecutor` | +| `post-launch-actions.ts` | companion + dev server after terminals | +| `workspace-health.ts` | subset of health | +| `suggest-commands.ts` | shells out to Core Suggest CLI | +| `settings.ts` | settings prefs | + +Raycast persistence (`storage.ts`) owns both LocalStorage keys above. Public mutations serialize on an in-process write queue (nested save/flush use unlocked helpers). Reset-all writes a restart-safe snapshot to `quickshell-data.bak` before clearing workspaces; recover with in-session Undo or **Restore Backup** after restart. See [persistence.md](./persistence.md#raycast). -Parity goals: multi-launch tabs (no `-w` on tab segments), similar settings keys, similar workspace shape, Suggest.exe pills with local heuristic fallback, multi-companion form + installed presets + folder-marker seed, trust/import contracts aligned with Core, copyable launch diagnostics, companions before terminals. Gaps: shared LocalAppData store, git worktree targets / dirty gate, full Core health (ports/process), Adaptive Card forms. +Parity goals: multi-launch tabs (Windows Terminal on Windows; Terminal.app / iTerm2 on macOS), similar settings keys, similar workspace shape, Suggest.exe pills with local heuristic fallback, multi-companion form + installed presets + folder-marker seed, trust/import contracts aligned with Core, copyable launch diagnostics, companions before terminals. Gaps: shared LocalAppData store, shared git worktree target file, full Core health (ports/process), Adaptive Card forms, Suggest CLI on macOS. ## Shared Core (desktop only) @@ -72,22 +74,22 @@ Anything that must stay consistent between CmdPal and Run belongs in **QuickShel ## Packaging notes -- Microsoft Store / CmdPal-only WinGet: extension without Run. -- Bundled WinGet / GH setup: CmdPal + Run plugin (restart PowerToys for Run). +- Microsoft Store / CmdPal-only WinGet: extension without Run. +- Bundled WinGet / GH setup: CmdPal + Run plugin (restart PowerToys for Run). - Raycast: [Raycast Store](https://www.raycast.com/store) only (no GitHub/WinGet sideload packages). Local packaging via `scripts/build-raycast-extension.ps1` for Store/dev. ## Key files -| Host | Entry | -|------|--------| -| CmdPal | `QuickShell/QuickShellCommandsProvider.cs` | -| Run | `QuickShell.Run/Main.cs` | +| Host | Entry | +| ------- | -------------------------------------------------- | +| CmdPal | `QuickShell/QuickShellCommandsProvider.cs` | +| Run | `QuickShell.Run/Main.cs` | | Raycast | `package.json` commands + `src/open-workspace.tsx` | -| Suggest | `QuickShell.Suggest/Program.cs` | +| Suggest | `QuickShell.Suggest/Program.cs` | ## Related -- [overview.md](./overview.md) -- [launch.md](./launch.md) -- [settings.md](./settings.md) -- [post-launch.md](./post-launch.md) +- [overview.md](./overview.md) +- [launch.md](./launch.md) +- [settings.md](./settings.md) +- [post-launch.md](./post-launch.md) diff --git a/docs/architecture/parity-matrix.md b/docs/architecture/parity-matrix.md index c9abb0e5..da175f4b 100644 --- a/docs/architecture/parity-matrix.md +++ b/docs/architecture/parity-matrix.md @@ -21,7 +21,8 @@ Data stores: | Store | CmdPal | Run | Raycast | |-------|--------|-----|---------| -| Workspaces | `%LOCALAPPDATA%\QuickShell\shortcuts.json` | **Shared** with CmdPal | Raycast storage blob (separate) | +| Workspaces | `%LOCALAPPDATA%\QuickShell\shortcuts.json` | **Shared** with CmdPal | Raycast `quickshell-data` LocalStorage blob (separate) | +| Reset backup | `shortcuts.json.bak` (atomic replace) | **Shared** with CmdPal | Raycast `quickshell-data.bak` (reset-all snapshot; extension-owned) | | Settings | `%LOCALAPPDATA%\QuickShell\settings.json` | **Shared** with CmdPal | In Raycast `StoredData.settings` (+ prefs) | | Edit draft | `shortcut-edit-draft.json` | Via editor (not same draft file UX) | Form-local / storage patterns | | Worktree branch targets | `worktree-branch-targets.json` | **Shared** (via Core on launch) | **No** dedicated file | @@ -41,6 +42,7 @@ Data stores: | Import / export JSON | Full | Via settings / shared file | Full (extension import-export) | Desktop shares one file; Raycast is separate blob; import always untrusted | | Layout undo / redo | Full | Partial (plugin + editor) | Full (storage history) | | | Section separators | Full | Partial | Full (Raycast-local `layoutEntries`) | Desktop shared layout file; Raycast blob parallel | +| Reset all / backup restore | Full | Partial | Full (durable Raycast backup) | Raycast `resetAll` → `quickshell-data.bak`; restore after restart or Undo in-session; mutations serialized via write queue | --- @@ -83,7 +85,7 @@ See [settings.md](./settings.md). | Worktree target branch store | Full | Full (Core gate) | Full (Raycast-local `branchTargets` in blob) | **No shared** `worktree-branch-targets.json` | | Git launch gate (dirty block) | Full | Full | Full (TS `git-launch-gate`) | Same rules; Raycast-local targets | | Workspace status page | Full | No | No | CmdPal-only UI | -| Discover git repos | Full | No | Full | Different scanners | +| Discover git repos | Full | No | Full | Raycast Review uses Suggest.exe (+ heuristics fallback), same as create seed | | `GitRepoIndex` prewarm | Full | No | N/A | | See [git-and-discover.md](./git-and-discover.md), [launch.md](./launch.md). @@ -95,9 +97,10 @@ See [git-and-discover.md](./git-and-discover.md), [launch.md](./launch.md). | Capability | CmdPal | Run | Raycast | Notes | |------------|--------|-----|---------|--------| | Project classification | Full (Core) | Full (Core) | Partial (Suggest.exe + local heuristics) | | -| Suggestion pills | Full | Full (panel) | Full via **Suggest.exe** (local fallback) | Form seeds from Suggest when CLI present | -| Setup seed on create/discover | Full | Partial | Full (Suggest + heuristics) | | +| Suggestion pills | Full | Full (panel) | Full via **Suggest.exe** (local fallback) | Short preferred seed + leftover pills in Actions; `taskType` persisted | +| Setup seed on create/discover | Full | Partial | Full (Suggest + heuristics) | Discover Review shares `resolveWorkspaceSetupSuggestions` | | Companion catalog / detection | Full | Full (editor/settings) | Partial (multi-row + presets + folder markers) | No JetBrains/vswhere walks | +| Open companion apps (list action) | Full | Partial | Full | Raycast: all configured companions on demand | | Dev server URL + open on launch | Full | Full | Full | Companions before terminals; URL after | | Agent CLI PATH pills | No | No | No | Roadmap Tier 2 / PR C | diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 7b1df0c7..bed4acbe 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -12,9 +12,9 @@ Default path: `%LOCALAPPDATA%\QuickShell\shortcuts.json`. On disk is not “only shortcuts” — it is a **layout**: -| Kind | Meaning | -|------|---------| -| **Shortcut** | `TerminalShortcut` (workspace) | +| Kind | Meaning | +| ------------- | ------------------------------------------------------ | +| **Shortcut** | `TerminalShortcut` (workspace) | | **Separator** | Section header (`Type: "separator"`, optional `Title`) | In memory: @@ -33,7 +33,7 @@ In memory: ```json { "version": 1, - "entries": [ /* shortcuts + separators */ ] + "entries": [/* shortcuts + separators */] } ``` @@ -43,11 +43,11 @@ Workspace trust metadata is local-persistence-only and is documented in [trust-m **Read** accepts: -| Root | Behavior | -|------|----------| -| JSON **array** (legacy v0) | Parse entries | -| **Object** with `entries` | Envelope; `version > Current` → reject | -| Invalid / empty / oversize | Fail → restore last good / empty | +| Root | Behavior | +| -------------------------- | -------------------------------------- | +| JSON **array** (legacy v0) | Parse entries | +| **Object** with `entries` | Envelope; `version > Current` → reject | +| Invalid / empty / oversize | Fail → restore last good / empty | Limits: max ~**2 MB**, max shortcut count via `ShortcutValidation.MaxShortcutCount`. Valid shortcut: non-empty **Name** + **Directory**. @@ -55,7 +55,7 @@ JSON via source-generated `QuickShellJsonContext` (AOT-friendly). ## Load path -``` +```text EnsureConfigExists create config dir if missing/empty: try .bak, then legacy TerminalShortcutsCmdPal path, else empty file @@ -82,7 +82,7 @@ Used by Upsert, Delete, pin, Undo/Redo, Import, Reset: `AtomicFileWriter` / `IAtomicFileWriter`: -``` +```text write path.tmp → File.Replace(tmp, path, path.bak) or Move Global\QuickShell_shortcuts_json mutex (cross-process) ``` @@ -97,7 +97,7 @@ In-process API serialized with `SemaphoreSlim`. Typical pattern: -``` +```text WithLock { EnsureLoaded(); CancelPendingPersist(); previous = clone(layout); mutate clone; @@ -110,13 +110,13 @@ WithLock { ## Import / export -| Op | Behavior | -|----|----------| -| **Export** | Current layout JSON to user path | -| **Import read** | Same parser as main store | -| **Merge** | Append; rename on name conflict; history + save | -| **Replace** | New layout from file; history + save | -| **Conflicts** | UI (`ImportConflictPage`) when names collide | +| Op | Behavior | +| --------------- | ----------------------------------------------- | +| **Export** | Current layout JSON to user path | +| **Import read** | Same parser as main store | +| **Merge** | Append; rename on name conflict; history + save | +| **Replace** | New layout from file; history + save | +| **Conflicts** | UI (`ImportConflictPage`) when names collide | Import is **undoable** as one layout history step. @@ -124,35 +124,49 @@ Import is **undoable** as one layout history step. On load, `WorkspaceLegacyMigration`: -1. If `%LOCALAPPDATA%\QuickShell\workspaces.json` exists -2. Parse `WorkspaceDiskRecord` list → normalize → `TerminalShortcut` -3. Merge (rename collisions) + save +1. If `%LOCALAPPDATA%\QuickShell\workspaces.json` exists +2. Parse `WorkspaceDiskRecord` list → normalize → `TerminalShortcut` +3. Merge (rename collisions) + save 4. Archive to `workspaces.json.migrated` Also first-time import from `shortcuts.json.bak` or old product path `TerminalShortcutsCmdPal\shortcuts.json`. ## Sibling stores -| File | Role | -|------|------| -| `shortcut-edit-draft.json` | Form draft for **edit** (see [forms.md](./forms.md)) | +| File | Role | +| ------------------------------ | ---------------------------------------------------- | +| `shortcut-edit-draft.json` | Form draft for **edit** (see [forms.md](./forms.md)) | | `worktree-branch-targets.json` | Git targets (atomic writer; not in workspace export) | -| `settings.json` | Host preferences (not `ShortcutRepository`) | +| `settings.json` | Host preferences (not `ShortcutRepository`) | ## Raycast -`QuickShellStorage` mirrors layout/undo/recent debounce in Raycast storage API — **does not** share the desktop JSON path unless the user imports or exports. +`QuickShellStorage` (`QuickShell.Raycast/src/lib/storage.ts`) is the Raycast persistence spine. It mirrors desktop layout / undo / recent-write debounce over the Raycast `LocalStorage` API and **does not** share `%LOCALAPPDATA%\QuickShell\` unless the user imports or exports JSON. + +| Key (`schema.ts`) | Owner | Role | +| -------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------- | +| `quickshell-data` (`STORAGE_KEY`) | `QuickShellStorage` | Live `StoredData` blob (workspaces, layout, security, branch targets, settings mirror) | +| `quickshell-data.bak` (`BACKUP_STORAGE_KEY`) | `QuickShellStorage` | Durable reset-all snapshot; Raycast-local only (not the desktop `.bak` beside `shortcuts.json`) | + +**Mutation serialization.** Public mutators (save, upsert/delete, pin/reorder, import, reset/restore, trust, flush of debounced recent writes, …) run through an in-process write queue (`withWriteLock`). Concurrent Raycast commands cannot silently clobber each other with a last-writer-wins race. Nested composition inside a held lock calls private `saveUnlocked` / `flushRecentWritesUnlocked` so callers always queue at the public boundary. + +**Reset all / restore.** `resetAll()` writes the current cache to `BACKUP_STORAGE_KEY`, then clears workspaces / layout / security / branch targets while preserving settings, and records an undo snapshot. Recovery: + +1. **In-session:** Undo (same as other layout mutations; lost if the extension process restarts). +2. **After restart:** `restoreFromBackup()` reloads workspaces from the durable backup key into the live store while keeping the current settings (post-reset preference changes survive). Corrupt or malformed backup JSON is discarded (key cleared) with a clear message rather than hard-failing forever or wiping live data. + +UI: Transfer actions on the workspaces hub (`open-workspace.tsx`) — confirmed **Reset All Workspaces…** and **Restore Backup…** when a backup exists. ## Key files -| File | Role | -|------|------| -| `ShortcutRepository.cs` | Load/save/undo/import | -| `ShortcutLayoutJson.cs` | Parse/serialize | -| `AtomicFileWriter.cs` | Temp + replace | -| `WorkspaceLegacyMigration.cs` | Old workspaces.json | -| `ShortcutDraftStore.cs` | Edit drafts | -| `WorktreeBranchTargetStore.cs` | Branch targets | +| File | Role | +| ------------------------------ | --------------------- | +| `ShortcutRepository.cs` | Load/save/undo/import | +| `ShortcutLayoutJson.cs` | Parse/serialize | +| `AtomicFileWriter.cs` | Temp + replace | +| `WorkspaceLegacyMigration.cs` | Old workspaces.json | +| `ShortcutDraftStore.cs` | Edit drafts | +| `WorktreeBranchTargetStore.cs` | Branch targets | ## Tests @@ -160,13 +174,13 @@ Also first-time import from `shortcuts.json.bak` or old product path `TerminalSh ## Gotchas -1. Layout is king; arrays/indexes are projections. -2. MarkUsed can drop if process dies within debounce. -3. Separators only round-trip via full layout export/import. -4. Settings and branch targets are **not** in workspace export. +1. Layout is king; arrays/indexes are projections. +2. MarkUsed can drop if process dies within debounce. +3. Separators only round-trip via full layout export/import. +4. Settings and branch targets are **not** in workspace export. 5. Proposal doc 0002 may describe gaps already fixed — verify code. ## Related -- [forms.md](./forms.md) — draft file + form undo -- [overview.md](./overview.md) — data directory map +- [forms.md](./forms.md) — draft file + form undo +- [overview.md](./overview.md) — data directory map