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