From d42a762e7236fd457e368d4de0b0e732749817fe Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 18:57:58 -0700 Subject: [PATCH 01/24] Close Raycast gaps for storage write locking and reset-all backup. Serialize workspace mutations so overlapping commands cannot lose updates, and add confirmed reset-all with a durable backup restore path matching CmdPal. Co-authored-by: Cursor --- .../src/__tests__/storage.test.ts | 54 ++ QuickShell.Raycast/src/lib/schema.ts | 3 + QuickShell.Raycast/src/lib/storage.ts | 753 ++++++++++-------- QuickShell.Raycast/src/open-workspace.tsx | 55 +- 4 files changed, 553 insertions(+), 312 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index 5f16909b..3734032f 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -361,4 +361,58 @@ 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("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.message).toMatch(/no workspaces/i); + expect(await storage.hasBackup()).toBe(false); + }); + + it("resetAll clears workspaces, keeps undo, and writes a durable backup", async () => { + const adapter = createMemoryStorageAdapter(); + const storage = new QuickShellStorage(adapter); + const id = createStableId(); + await storage.upsertWorkspace(createWorkspace(id, "Alpha")); + + const result = await storage.resetAll(); + expect(result.success).toBe(true); + expect(await storage.getWorkspaces()).toHaveLength(0); + expect(await storage.hasBackup()).toBe(true); + expect(storage.canUndo()).toBe(true); + + 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(await restarted.getWorkspaces()).toHaveLength(1); + expect((await restarted.getWorkspaces())[0].name).toBe("Alpha"); + }); }); diff --git a/QuickShell.Raycast/src/lib/schema.ts b/QuickShell.Raycast/src/lib/schema.ts index b73bd234..245b671b 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 = { diff --git a/QuickShell.Raycast/src/lib/storage.ts b/QuickShell.Raycast/src/lib/storage.ts index 4f189e82..877751d7 100644 --- a/QuickShell.Raycast/src/lib/storage.ts +++ b/QuickShell.Raycast/src/lib/storage.ts @@ -7,7 +7,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,6 +32,11 @@ export type StorageAdapter = { setItem: (key: string, value: string) => Promise; }; +export type StorageTransferResult = { + success: true; + message: string; +}; + const MAX_HISTORY_ENTRIES = 25; const RECENT_WRITE_DEBOUNCE_MS = 500; @@ -41,6 +46,9 @@ 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(); + private writeDepth = 0; constructor( private readonly adapter: StorageAdapter, @@ -60,44 +68,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.flushRecentWrites(); + 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.flushRecentWrites(); + 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 +127,65 @@ 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.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; + }); + } + + /** + * 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.flushRecentWrites(); + await this.ensureLoaded(); + if (this.cache!.workspaces.length === 0) { + return { success: true, 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.save(emptied, { preserveSecurity: false, allowSubmittedSecurity: true }); + + const itemsLabel = count === 1 ? "workspace" : "workspaces"; + return { + success: true, + 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.flushRecentWrites(); + const raw = await this.adapter.getItem(BACKUP_STORAGE_KEY); + if (!raw) { + return { success: true, message: "No workspace backup found." }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Workspace backup is not valid JSON."); + } + + const restored = migrateStoredData(parsed); + await this.save(restored, { preserveSecurity: false, allowSubmittedSecurity: true }); + return { + success: true, + message: `Restored ${restored.workspaces.length} workspace${restored.workspaces.length === 1 ? "" : "s"} from backup.`, + }; + }); } async summarizeImport(raw: string, mode: "merge" | "replace" = "merge"): Promise { @@ -130,54 +201,56 @@ export class QuickShellStorage { allowSubmittedSecurity?: boolean; }, ): Promise { - const recordHistory = options?.recordHistory ?? true; - const preserveSecurity = options?.preserveSecurity ?? true; - const allowSubmittedSecurity = options?.allowSubmittedSecurity ?? false; - - const normalized: StoredData = { - version: data.version, - settings: { ...data.settings }, - workspaces: data.workspaces.map((workspace) => normalizeWorkspace({ ...workspace })), - workspaceSecurity: {}, - branchTargets: { ...(data.branchTargets ?? {}) }, - layoutEntries: syncLayoutEntries(data.layoutEntries, data.workspaces), - }; + return this.withWriteLock(async () => { + const recordHistory = options?.recordHistory ?? true; + const preserveSecurity = options?.preserveSecurity ?? true; + const allowSubmittedSecurity = options?.allowSubmittedSecurity ?? false; + + const normalized: StoredData = { + version: data.version, + settings: { ...data.settings }, + workspaces: data.workspaces.map((workspace) => normalizeWorkspace({ ...workspace })), + workspaceSecurity: {}, + branchTargets: { ...(data.branchTargets ?? {}) }, + layoutEntries: syncLayoutEntries(data.layoutEntries, data.workspaces), + }; - for (const workspace of normalized.workspaces) { - const prior = this.cache?.workspaceSecurity?.[workspace.id]; - const submitted = data.workspaceSecurity?.[workspace.id]; - if (preserveSecurity && prior) { - const previousWorkspace = this.cache?.workspaces.find((candidate) => candidate.id === workspace.id); - const changed = JSON.stringify(previousWorkspace) !== JSON.stringify(workspace); - normalized.workspaceSecurity![workspace.id] = { - ...prior, - revision: changed ? prior.revision + 1 : prior.revision, - }; - } else if (allowSubmittedSecurity && submitted) { - normalized.workspaceSecurity![workspace.id] = { ...submitted }; - } else { - normalized.workspaceSecurity![workspace.id] = createIngressSecurity(); + for (const workspace of normalized.workspaces) { + const prior = this.cache?.workspaceSecurity?.[workspace.id]; + const submitted = data.workspaceSecurity?.[workspace.id]; + if (preserveSecurity && prior) { + const previousWorkspace = this.cache?.workspaces.find((candidate) => candidate.id === workspace.id); + const changed = JSON.stringify(previousWorkspace) !== JSON.stringify(workspace); + normalized.workspaceSecurity![workspace.id] = { + ...prior, + revision: changed ? prior.revision + 1 : prior.revision, + }; + } else if (allowSubmittedSecurity && submitted) { + normalized.workspaceSecurity![workspace.id] = { ...submitted }; + } else { + normalized.workspaceSecurity![workspace.id] = createIngressSecurity(); + } } - } - const countResult = validateWorkspaceCount(normalized.workspaces.length); - if (!countResult.ok) { - throw new Error(countResult.message); - } + const countResult = validateWorkspaceCount(normalized.workspaces.length); + if (!countResult.ok) { + throw new Error(countResult.message); + } - for (const workspace of normalized.workspaces) { - const validation = validateWorkspace(workspace); - if (!validation.ok) { - throw new Error(`${workspace.name || workspace.id}: ${validation.message}`); + for (const workspace of normalized.workspaces) { + const validation = validateWorkspace(workspace); + if (!validation.ok) { + throw new Error(`${workspace.name || workspace.id}: ${validation.message}`); + } } - } - if (recordHistory && this.cache) { - this.pushUndoSnapshot(this.cache); - } + if (recordHistory && this.cache) { + this.pushUndoSnapshot(this.cache); + } - this.cache = normalized; - await this.persistCache({ recordHistory: false }); + this.cache = normalized; + await this.persistCache({ recordHistory: false }); + }); } async getWorkspaces(): Promise { @@ -220,45 +293,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.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"; + }); } 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.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"; + }); } async getSettings(): Promise { @@ -271,84 +348,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.flushRecentWrites(); + 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.save(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.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); + }); } 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.flushRecentWrites(); + 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.save(data, { preserveSecurity: false, allowSubmittedSecurity: true }); + return duplicate; + }); } async getBranchTargets(): Promise> { @@ -362,31 +445,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.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); + }); } 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.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); + }); } async getLayoutEntries(): Promise { @@ -395,141 +482,155 @@ 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.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; + }); } 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.flushRecentWrites(); + const data = await this.load(); + data.layoutEntries = (data.layoutEntries ?? []).filter((entry) => !layoutEntryMatchesId(entry, entryId)); + await this.save(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.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); + }); } 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.flushRecentWrites(); + 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.save(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.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."); + } - // 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.save(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 { - if (this.recentWriteTimer) { - clearTimeout(this.recentWriteTimer); - this.recentWriteTimer = null; - } - if (!this.recentWriteDirty || !this.cache) { - return; - } - this.recentWriteDirty = false; - await this.persistCache({ recordHistory: false }); + return this.withWriteLock(async () => { + if (this.recentWriteTimer) { + clearTimeout(this.recentWriteTimer); + this.recentWriteTimer = null; + } + if (!this.recentWriteDirty || !this.cache) { + return; + } + this.recentWriteDirty = false; + await this.persistCache({ recordHistory: false }); + }); } async updateSettings(settings: QuickShellSettings): Promise { @@ -537,10 +638,40 @@ export class QuickShellStorage { 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.flushRecentWrites(); + const data = await this.load(); + data.settings = { ...settings }; + await this.save(data); + }); + } + + /** + * Runs `operation` exclusively against other writers. + * Re-entrant so nested save/flush calls from an outer mutator do not deadlock. + */ + private async withWriteLock(operation: () => Promise): Promise { + if (this.writeDepth > 0) { + return operation(); + } + + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const previous = this.writeTail; + this.writeTail = previous.then( + () => gate, + () => gate, + ); + await previous; + this.writeDepth += 1; + try { + return await operation(); + } finally { + this.writeDepth -= 1; + release(); + } } private async ensureLoaded(): Promise { diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index 2e37555a..263515b4 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -78,6 +78,7 @@ type LoadedData = { healthIndex: WorkspaceHealthIndex; canUndo: boolean; canRedo: boolean; + hasBackup: boolean; }; type WorkspaceRow = { @@ -122,11 +123,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 +143,7 @@ export default function OpenWorkspaceCommand({ securityById, canUndo: storage.canUndo(), canRedo: storage.canRedo(), + hasBackup, }; }, []); @@ -591,6 +594,49 @@ export default function OpenWorkspaceCommand({ } } + async function handleResetAll() { + const count = data?.workspaces.length ?? 0; + 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(); + await showToast({ style: Toast.Style.Success, title: "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(); + await showToast({ style: Toast.Style.Success, title: "Backup restored", message: result.message }); + } catch (restoreError) { + await showStorageFailure("Restore backup", restoreError); + } + } + async function handleUndo() { const changed = await storage.undo(); if (!changed) { @@ -760,6 +806,13 @@ export default function OpenWorkspaceCommand({ + + {data?.hasBackup ? : null} ); From 2db6dca4f330dbf9966ce8ba1ad30b5d0ed4ff2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 02:55:24 +0000 Subject: [PATCH 02/24] Fix Raycast write-lock reentrancy and harden reset backup recovery. Replace shared writeDepth bypass with unlocked nested helpers so concurrent mutations always queue, discard corrupt backups gracefully, strengthen storage tests, and update persistence host docs. Co-authored-by: Anthony Thompson --- .../src/__tests__/storage.test.ts | 90 +++++++- QuickShell.Raycast/src/lib/storage.ts | 207 +++++++++--------- docs/architecture/hosts.md | 2 + docs/architecture/parity-matrix.md | 1 + docs/architecture/persistence.md | 2 +- 5 files changed, 199 insertions(+), 103 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index 3734032f..2b29ca70 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { QuickShellStorage, 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(() => { @@ -374,6 +374,61 @@ describe("storage", () => { 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 base = createMemoryStorageAdapter(); + const adapter = { + getItem: (key: string) => base.getItem(key), + async setItem(key: string, value: string) { + writeCount += 1; + if (writeCount === 1) { + signalFirstWriteStarted(); + await firstWriteGate; + } + await base.setItem(key, value); + }, + }; + + 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]); + + 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(); @@ -382,18 +437,33 @@ describe("storage", () => { expect(await storage.hasBackup()).toBe(false); }); - it("resetAll clears workspaces, keeps undo, and writes a durable backup", async () => { + 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(); - await storage.upsertWorkspace(createWorkspace(id, "Alpha")); + 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(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"); @@ -415,4 +485,18 @@ describe("storage", () => { expect(await restarted.getWorkspaces()).toHaveLength(1); expect((await restarted.getWorkspaces())[0].name).toBe("Alpha"); }); + + 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.message).toMatch(/not valid JSON/i); + expect(await storage.hasBackup()).toBe(false); + + const again = await storage.restoreFromBackup(); + expect(again.message).toMatch(/no workspace backup/i); + }); }); diff --git a/QuickShell.Raycast/src/lib/storage.ts b/QuickShell.Raycast/src/lib/storage.ts index 877751d7..7a012fb1 100644 --- a/QuickShell.Raycast/src/lib/storage.ts +++ b/QuickShell.Raycast/src/lib/storage.ts @@ -32,7 +32,7 @@ export type StorageAdapter = { setItem: (key: string, value: string) => Promise; }; -export type StorageTransferResult = { +type StorageTransferResult = { success: true; message: string; }; @@ -48,7 +48,6 @@ export class QuickShellStorage { private recentWriteDirty = false; /** Serializes load-modify-save mutations so overlapping commands cannot clobber each other. */ private writeTail: Promise = Promise.resolve(); - private writeDepth = 0; constructor( private readonly adapter: StorageAdapter, @@ -75,7 +74,7 @@ export class QuickShellStorage { async undo(): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); if (this.undoHistory.length === 0) { return false; } @@ -97,7 +96,7 @@ export class QuickShellStorage { async redo(): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); if (this.redoHistory.length === 0) { return false; } @@ -128,10 +127,10 @@ export class QuickShellStorage { async importJson(raw: string, mode: "merge" | "replace" = "merge"): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const existing = mode === "merge" ? await this.load() : createEmptyStoredData(); const result = parseImportPayload(raw, existing); - await this.save(result.data, { preserveSecurity: false, allowSubmittedSecurity: true }); + await this.saveUnlocked(result.data, { preserveSecurity: false, allowSubmittedSecurity: true }); return result; }); } @@ -142,7 +141,7 @@ export class QuickShellStorage { */ async resetAll(): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); await this.ensureLoaded(); if (this.cache!.workspaces.length === 0) { return { success: true, message: "No workspaces to reset." }; @@ -153,7 +152,7 @@ export class QuickShellStorage { const emptied = createEmptyStoredData(); emptied.settings = { ...this.cache!.settings }; - await this.save(emptied, { preserveSecurity: false, allowSubmittedSecurity: true }); + await this.saveUnlocked(emptied, { preserveSecurity: false, allowSubmittedSecurity: true }); const itemsLabel = count === 1 ? "workspace" : "workspaces"; return { @@ -166,7 +165,7 @@ export class QuickShellStorage { /** Restores the durable reset-all backup into the live store. */ async restoreFromBackup(): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const raw = await this.adapter.getItem(BACKUP_STORAGE_KEY); if (!raw) { return { success: true, message: "No workspace backup found." }; @@ -176,11 +175,15 @@ export class QuickShellStorage { try { parsed = JSON.parse(raw); } catch { - throw new Error("Workspace backup is not valid JSON."); + await this.adapter.setItem(BACKUP_STORAGE_KEY, ""); + return { + success: true, + message: "Workspace backup was not valid JSON and has been discarded.", + }; } const restored = migrateStoredData(parsed); - await this.save(restored, { preserveSecurity: false, allowSubmittedSecurity: true }); + await this.saveUnlocked(restored, { preserveSecurity: false, allowSubmittedSecurity: true }); return { success: true, message: `Restored ${restored.workspaces.length} workspace${restored.workspaces.length === 1 ? "" : "s"} from backup.`, @@ -193,7 +196,7 @@ export class QuickShellStorage { return summarizeImportConflicts(raw, existing); } - async save( + private async saveUnlocked( data: StoredData, options?: { recordHistory?: boolean; @@ -201,56 +204,65 @@ export class QuickShellStorage { allowSubmittedSecurity?: boolean; }, ): Promise { - return this.withWriteLock(async () => { - const recordHistory = options?.recordHistory ?? true; - const preserveSecurity = options?.preserveSecurity ?? true; - const allowSubmittedSecurity = options?.allowSubmittedSecurity ?? false; - - const normalized: StoredData = { - version: data.version, - settings: { ...data.settings }, - workspaces: data.workspaces.map((workspace) => normalizeWorkspace({ ...workspace })), - workspaceSecurity: {}, - branchTargets: { ...(data.branchTargets ?? {}) }, - layoutEntries: syncLayoutEntries(data.layoutEntries, data.workspaces), - }; + const recordHistory = options?.recordHistory ?? true; + const preserveSecurity = options?.preserveSecurity ?? true; + const allowSubmittedSecurity = options?.allowSubmittedSecurity ?? false; - for (const workspace of normalized.workspaces) { - const prior = this.cache?.workspaceSecurity?.[workspace.id]; - const submitted = data.workspaceSecurity?.[workspace.id]; - if (preserveSecurity && prior) { - const previousWorkspace = this.cache?.workspaces.find((candidate) => candidate.id === workspace.id); - const changed = JSON.stringify(previousWorkspace) !== JSON.stringify(workspace); - normalized.workspaceSecurity![workspace.id] = { - ...prior, - revision: changed ? prior.revision + 1 : prior.revision, - }; - } else if (allowSubmittedSecurity && submitted) { - normalized.workspaceSecurity![workspace.id] = { ...submitted }; - } else { - normalized.workspaceSecurity![workspace.id] = createIngressSecurity(); - } - } + const normalized: StoredData = { + version: data.version, + settings: { ...data.settings }, + workspaces: data.workspaces.map((workspace) => normalizeWorkspace({ ...workspace })), + workspaceSecurity: {}, + branchTargets: { ...(data.branchTargets ?? {}) }, + layoutEntries: syncLayoutEntries(data.layoutEntries, data.workspaces), + }; - const countResult = validateWorkspaceCount(normalized.workspaces.length); - if (!countResult.ok) { - throw new Error(countResult.message); + for (const workspace of normalized.workspaces) { + const prior = this.cache?.workspaceSecurity?.[workspace.id]; + const submitted = data.workspaceSecurity?.[workspace.id]; + if (preserveSecurity && prior) { + const previousWorkspace = this.cache?.workspaces.find((candidate) => candidate.id === workspace.id); + const changed = JSON.stringify(previousWorkspace) !== JSON.stringify(workspace); + normalized.workspaceSecurity![workspace.id] = { + ...prior, + revision: changed ? prior.revision + 1 : prior.revision, + }; + } else if (allowSubmittedSecurity && submitted) { + normalized.workspaceSecurity![workspace.id] = { ...submitted }; + } else { + normalized.workspaceSecurity![workspace.id] = createIngressSecurity(); } + } - for (const workspace of normalized.workspaces) { - const validation = validateWorkspace(workspace); - if (!validation.ok) { - throw new Error(`${workspace.name || workspace.id}: ${validation.message}`); - } - } + const countResult = validateWorkspaceCount(normalized.workspaces.length); + if (!countResult.ok) { + throw new Error(countResult.message); + } - if (recordHistory && this.cache) { - this.pushUndoSnapshot(this.cache); + for (const workspace of normalized.workspaces) { + const validation = validateWorkspace(workspace); + if (!validation.ok) { + throw new Error(`${workspace.name || workspace.id}: ${validation.message}`); } + } - this.cache = normalized; - await this.persistCache({ recordHistory: false }); - }); + if (recordHistory && this.cache) { + this.pushUndoSnapshot(this.cache); + } + + this.cache = normalized; + await this.persistCache({ recordHistory: false }); + } + + async save( + data: StoredData, + options?: { + recordHistory?: boolean; + preserveSecurity?: boolean; + allowSubmittedSecurity?: boolean; + }, + ): Promise { + return this.withWriteLock(() => this.saveUnlocked(data, options)); } async getWorkspaces(): Promise { @@ -294,7 +306,7 @@ export class QuickShellStorage { reviewToken: WorkspaceReviewToken, ): Promise<"granted" | "already" | "changed" | "invalid" | "missing"> { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const workspace = data.workspaces.find((candidate) => candidate.id === workspaceId); if (!workspace) { @@ -314,14 +326,14 @@ export class QuickShellStorage { } data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; data.workspaceSecurity[workspaceId] = { isTrusted: true, revision: security.revision + 1 }; - await this.save(data, { preserveSecurity: false, allowSubmittedSecurity: true }); + await this.saveUnlocked(data, { preserveSecurity: false, allowSubmittedSecurity: true }); return "granted"; }); } async revokeTrust(workspaceId: string): Promise<"revoked" | "already" | "missing"> { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const workspace = data.workspaces.find((candidate) => candidate.id === workspaceId); if (!workspace) { @@ -333,7 +345,7 @@ export class QuickShellStorage { } data.workspaceSecurity = { ...(data.workspaceSecurity ?? {}) }; data.workspaceSecurity[workspaceId] = { isTrusted: false, revision: security.revision + 1 }; - await this.save(data, { preserveSecurity: false, allowSubmittedSecurity: true }); + await this.saveUnlocked(data, { preserveSecurity: false, allowSubmittedSecurity: true }); return "revoked"; }); } @@ -349,7 +361,7 @@ export class QuickShellStorage { async upsertWorkspace(workspace: Workspace): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const normalized = normalizeWorkspace({ ...workspace, @@ -384,26 +396,26 @@ export class QuickShellStorage { data.layoutEntries = [...(data.layoutEntries ?? []), { type: "workspace", workspaceId: normalized.id }]; } - await this.save(data, { allowSubmittedSecurity: true }); + await this.saveUnlocked(data, { allowSubmittedSecurity: true }); return normalized; }); } async deleteWorkspace(workspaceId: string): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + 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.save(data); + await this.saveUnlocked(data); }); } async duplicateWorkspace(workspaceId: string): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const source = data.workspaces.find((workspace) => workspace.id === workspaceId); if (!source) { @@ -429,7 +441,7 @@ export class QuickShellStorage { 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 }); + await this.saveUnlocked(data, { preserveSecurity: false, allowSubmittedSecurity: true }); return duplicate; }); } @@ -446,7 +458,7 @@ export class QuickShellStorage { async setBranchTarget(worktreeKey: string, branch: string): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const key = worktreeKey.trim().toLowerCase(); const value = branch.trim(); @@ -457,13 +469,13 @@ export class QuickShellStorage { throw new Error("Invalid branch name."); } data.branchTargets = { ...(data.branchTargets ?? {}), [key]: value }; - await this.save(data); + await this.saveUnlocked(data); }); } async clearBranchTarget(worktreeKey: string): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const key = worktreeKey.trim().toLowerCase(); if (!data.branchTargets || !(key in data.branchTargets)) { @@ -472,7 +484,7 @@ export class QuickShellStorage { const next = { ...data.branchTargets }; delete next[key]; data.branchTargets = next; - await this.save(data); + await this.saveUnlocked(data); }); } @@ -483,7 +495,7 @@ export class QuickShellStorage { async insertSeparator(title?: string | null, beforeWorkspaceId?: string): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const separator: LayoutEntry = { type: "separator", @@ -500,23 +512,23 @@ export class QuickShellStorage { layout.push(separator); } data.layoutEntries = layout; - await this.save(data); + await this.saveUnlocked(data); return separator; }); } async removeLayoutEntry(entryId: string): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); data.layoutEntries = (data.layoutEntries ?? []).filter((entry) => !layoutEntryMatchesId(entry, entryId)); - await this.save(data); + await this.saveUnlocked(data); }); } async moveLayoutEntry(entryId: string, direction: "up" | "down"): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const layout = [...(data.layoutEntries ?? [])]; const index = layout.findIndex((entry) => layoutEntryMatchesId(entry, entryId)); @@ -531,13 +543,13 @@ export class QuickShellStorage { layout[index] = layout[swapIndex]; layout[swapIndex] = current; data.layoutEntries = layout; - await this.save(data); + await this.saveUnlocked(data); }); } async setFavorite(workspaceId: string, isPinned: boolean): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const workspace = data.workspaces.find((item) => item.id === workspaceId); if (!workspace) { @@ -554,7 +566,7 @@ export class QuickShellStorage { workspace.pinOrder = null; } - await this.save(data); + await this.saveUnlocked(data); return { ...workspace }; }); } @@ -562,7 +574,7 @@ export class QuickShellStorage { /** Returns the moved workspace, or `null` when the move is a boundary no-op. */ async moveFavorite(workspaceId: string, direction: "up" | "down" | "top" | "bottom"): Promise { return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); const workspace = data.workspaces.find((item) => item.id === workspaceId); if (!workspace || !workspace.isPinned) { @@ -601,7 +613,7 @@ export class QuickShellStorage { item.pinOrder = orderIndex + 1; }); - await this.save(data); + await this.saveUnlocked(data); return { ...favorites[targetIndex] }; }); } @@ -619,18 +631,20 @@ export class QuickShellStorage { }); } + private async flushRecentWritesUnlocked(): Promise { + if (this.recentWriteTimer) { + clearTimeout(this.recentWriteTimer); + this.recentWriteTimer = null; + } + if (!this.recentWriteDirty || !this.cache) { + return; + } + this.recentWriteDirty = false; + await this.persistCache({ recordHistory: false }); + } + async flushRecentWrites(): Promise { - return this.withWriteLock(async () => { - if (this.recentWriteTimer) { - clearTimeout(this.recentWriteTimer); - this.recentWriteTimer = null; - } - if (!this.recentWriteDirty || !this.cache) { - return; - } - this.recentWriteDirty = false; - await this.persistCache({ recordHistory: false }); - }); + return this.withWriteLock(() => this.flushRecentWritesUnlocked()); } async updateSettings(settings: QuickShellSettings): Promise { @@ -639,22 +653,19 @@ export class QuickShellStorage { } return this.withWriteLock(async () => { - await this.flushRecentWrites(); + await this.flushRecentWritesUnlocked(); const data = await this.load(); data.settings = { ...settings }; - await this.save(data); + await this.saveUnlocked(data); }); } /** * Runs `operation` exclusively against other writers. - * Re-entrant so nested save/flush calls from an outer mutator do not deadlock. + * Nested composition must call saveUnlocked / flushRecentWritesUnlocked instead of + * public save / flushRecentWrites so concurrent callers always queue. */ private async withWriteLock(operation: () => Promise): Promise { - if (this.writeDepth > 0) { - return operation(); - } - let release!: () => void; const gate = new Promise((resolve) => { release = resolve; @@ -665,11 +676,9 @@ export class QuickShellStorage { () => gate, ); await previous; - this.writeDepth += 1; try { return await operation(); } finally { - this.writeDepth -= 1; release(); } } diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index 3cd7fbba..39ea13e5 100644 --- a/docs/architecture/hosts.md +++ b/docs/architecture/hosts.md @@ -64,6 +64,8 @@ Important libs: | `suggest-commands.ts` | shells out to Core Suggest CLI | | `settings.ts` | settings prefs | +Raycast `storage.ts` serializes mutations with an in-process write queue. Its reset-all flow owns a durable backup key, allowing restore after restart (with Undo available in-session). + 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. ## Shared Core (desktop only) diff --git a/docs/architecture/parity-matrix.md b/docs/architecture/parity-matrix.md index c9abb0e5..ffa1cdca 100644 --- a/docs/architecture/parity-matrix.md +++ b/docs/architecture/parity-matrix.md @@ -41,6 +41,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 backup is local to the extension and survives restart; mutations are serialized | --- diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 7b1df0c7..90037dde 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -141,7 +141,7 @@ Also first-time import from `shortcuts.json.bak` or old product path `TerminalSh ## 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` mirrors layout/undo/recent debounce in Raycast storage API and serializes all mutations behind an in-process write queue — **does not** share the desktop JSON path unless the user imports or exports. Reset-all writes a durable, restart-safe backup snapshot under the Raycast-owned backup key (`quickshell-data.bak`); users can restore it after restart (or use in-session Undo). Nested save/flush work uses private unlocked helpers so concurrent public mutations always queue. ## Key files From 61804296f88fbab1b8a7c10bad76e61e03e63b2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 03:05:19 +0000 Subject: [PATCH 03/24] Address CodeRabbit feedback on Raycast storage reset UX. Add ALS nested-lock detection, flush before public save, outcome-aware toasts, load-state guard for reset confirm, stronger write-order test, and destructive styling for Restore Backup. Co-authored-by: Anthony Thompson --- .../src/__tests__/storage.test.ts | 14 +++++++- QuickShell.Raycast/src/lib/storage.ts | 23 +++++++++--- QuickShell.Raycast/src/open-workspace.tsx | 35 ++++++++++++++++--- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index 2b29ca70..dfff2c39 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -400,17 +400,21 @@ describe("storage", () => { 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; - if (writeCount === 1) { + const id = writeCount; + writeOrder.push(`start:${id}`); + if (id === 1) { signalFirstWriteStarted(); await firstWriteGate; } await base.setItem(key, value); + writeOrder.push(`end:${id}`); }, }; @@ -424,6 +428,9 @@ describe("storage", () => { 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"]); @@ -433,6 +440,7 @@ describe("storage", () => { 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); }); @@ -452,6 +460,7 @@ describe("storage", () => { 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); @@ -482,6 +491,7 @@ describe("storage", () => { 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"); }); @@ -493,10 +503,12 @@ describe("storage", () => { 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); }); }); diff --git a/QuickShell.Raycast/src/lib/storage.ts b/QuickShell.Raycast/src/lib/storage.ts index 7a012fb1..1d6a9627 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, @@ -34,9 +35,13 @@ export type StorageAdapter = { 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; @@ -144,7 +149,7 @@ export class QuickShellStorage { await this.flushRecentWritesUnlocked(); await this.ensureLoaded(); if (this.cache!.workspaces.length === 0) { - return { success: true, message: "No workspaces to reset." }; + return { success: true, outcome: "noop", message: "No workspaces to reset." }; } const count = this.cache!.workspaces.length; @@ -157,6 +162,7 @@ export class QuickShellStorage { 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.`, }; }); @@ -168,7 +174,7 @@ export class QuickShellStorage { await this.flushRecentWritesUnlocked(); const raw = await this.adapter.getItem(BACKUP_STORAGE_KEY); if (!raw) { - return { success: true, message: "No workspace backup found." }; + return { success: true, outcome: "noop", message: "No workspace backup found." }; } let parsed: unknown; @@ -178,6 +184,7 @@ export class QuickShellStorage { await this.adapter.setItem(BACKUP_STORAGE_KEY, ""); return { success: true, + outcome: "discarded", message: "Workspace backup was not valid JSON and has been discarded.", }; } @@ -186,6 +193,7 @@ export class QuickShellStorage { 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.`, }; }); @@ -262,7 +270,10 @@ export class QuickShellStorage { allowSubmittedSecurity?: boolean; }, ): Promise { - return this.withWriteLock(() => this.saveUnlocked(data, options)); + return this.withWriteLock(async () => { + await this.flushRecentWritesUnlocked(); + await this.saveUnlocked(data, options); + }); } async getWorkspaces(): Promise { @@ -666,6 +677,10 @@ export class QuickShellStorage { * 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 release!: () => void; const gate = new Promise((resolve) => { release = resolve; @@ -677,7 +692,7 @@ export class QuickShellStorage { ); await previous; try { - return await operation(); + return await writeLockContext.run(true, operation); } finally { release(); } diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index 263515b4..bbfaa685 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -595,7 +595,12 @@ export default function OpenWorkspaceCommand({ } async function handleResetAll() { - const count = data?.workspaces.length ?? 0; + 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({ @@ -611,7 +616,12 @@ export default function OpenWorkspaceCommand({ try { const result = await storage.resetAll(); await revalidate(); - await showToast({ style: Toast.Style.Success, title: "Workspaces reset", message: result.message }); + const isNoop = result.outcome === "noop"; + await showToast({ + style: Toast.Style.Success, + title: isNoop ? "Nothing to reset" : "Workspaces reset", + message: result.message, + }); } catch (resetError) { await showStorageFailure("Reset workspaces", resetError); } @@ -631,7 +641,17 @@ export default function OpenWorkspaceCommand({ try { const result = await storage.restoreFromBackup(); await revalidate(); - await showToast({ style: Toast.Style.Success, title: "Backup restored", message: result.message }); + 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); } @@ -812,7 +832,14 @@ export default function OpenWorkspaceCommand({ style={Action.Style.Destructive} onAction={handleResetAll} /> - {data?.hasBackup ? : null} + {data?.hasBackup ? ( + + ) : null} ); From 3b32dfe65f7253a8eefdd59817341a1ffce02111 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 20:15:01 -0700 Subject: [PATCH 04/24] Document Raycast reset backup keys and write-queue serialization. Expand the persistence tour and host parity docs so reset-all, restart-safe restore, backup key ownership, and mutation serialization are explicit as-built behavior. Co-authored-by: Cursor --- docs/architecture/hosts.md | 10 +++++----- docs/architecture/parity-matrix.md | 5 +++-- docs/architecture/persistence.md | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index 39ea13e5..613e7abb 100644 --- a/docs/architecture/hosts.md +++ b/docs/architecture/hosts.md @@ -10,7 +10,7 @@ All hosts retain workspace IDs rather than trust-bearing snapshots and resolve c |---------|------------------------|-----------------------------------|----------------------------------| | **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 | +| **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` | @@ -55,8 +55,8 @@ Important libs: | Lib | Desktop analogue | |-----|------------------| -| `storage.ts` | `ShortcutRepository` (+ undo) | -| `schema.ts` / `migration.ts` | layout + version | +| `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 | @@ -64,9 +64,9 @@ Important libs: | `suggest-commands.ts` | shells out to Core Suggest CLI | | `settings.ts` | settings prefs | -Raycast `storage.ts` serializes mutations with an in-process write queue. Its reset-all flow owns a durable backup key, allowing restore after restart (with Undo available in-session). +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 (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, shared git worktree target file, full Core health (ports/process), Adaptive Card forms. ## Shared Core (desktop only) diff --git a/docs/architecture/parity-matrix.md b/docs/architecture/parity-matrix.md index ffa1cdca..6c987f79 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,7 +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 backup is local to the extension and survives restart; mutations are serialized | +| 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 | --- diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 90037dde..7ddd1b00 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -141,7 +141,21 @@ Also first-time import from `shortcuts.json.bak` or old product path `TerminalSh ## Raycast -`QuickShellStorage` mirrors layout/undo/recent debounce in Raycast storage API and serializes all mutations behind an in-process write queue — **does not** share the desktop JSON path unless the user imports or exports. Reset-all writes a durable, restart-safe backup snapshot under the Raycast-owned backup key (`quickshell-data.bak`); users can restore it after restart (or use in-session Undo). Nested save/flush work uses private unlocked helpers so concurrent public mutations always queue. +`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 the durable backup key into the live store. Corrupt backup JSON is discarded (key cleared) with a clear message rather than hard-failing forever. + +UI: Transfer actions on the workspaces hub (`open-workspace.tsx`) — confirmed **Reset All Workspaces…** and **Restore Backup…** when a backup exists. ## Key files From 1e9d9854ba53784f3a082f1c0343a54d7200409d Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 20:21:59 -0700 Subject: [PATCH 05/24] fix(raycast): add bounded write-lock acquisition timeout --- .../src/__tests__/storage.test.ts | 30 +++++++++++++++-- QuickShell.Raycast/src/lib/storage.ts | 32 +++++++++++++++++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index dfff2c39..d9df83da 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -1,5 +1,5 @@ -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 { BACKUP_STORAGE_KEY, createEmptyStoredData, DEFAULT_SETTINGS } from "../lib/schema"; @@ -511,4 +511,30 @@ describe("storage", () => { expect(again.outcome).toBe("noop"); expect(again.message).toMatch(/no workspace backup/i); }); + + 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(); + } + }); }); diff --git a/QuickShell.Raycast/src/lib/storage.ts b/QuickShell.Raycast/src/lib/storage.ts index 1d6a9627..45e3f8e0 100644 --- a/QuickShell.Raycast/src/lib/storage.ts +++ b/QuickShell.Raycast/src/lib/storage.ts @@ -44,6 +44,7 @@ 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; @@ -681,16 +682,41 @@ export class QuickShellStorage { throw new Error("Nested QuickShellStorage write lock: use saveUnlocked / flushRecentWritesUnlocked."); } - let release!: () => void; + let releaseGate!: () => void; + let releaseTimer: (() => void) | undefined; const gate = new Promise((resolve) => { - release = resolve; + releaseGate = resolve; }); + const release = () => { + releaseTimer?.(); + releaseGate(); + }; + const previous = this.writeTail; this.writeTail = previous.then( () => gate, () => gate, ); - await previous; + + 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 { From d36d1178c430e99c6530b61fc99005d445c61b37 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 20:22:40 -0700 Subject: [PATCH 06/24] Fix Raycast CmdPal import and unblock transfer file dialogs. Accept Core layout envelopes (`entries`, including Workspace wrappers) so CmdPal exports import correctly, and make the Windows file picker async with a startup toast so the UI does not freeze during PowerShell cold start. Co-authored-by: Cursor --- .../src/__tests__/import-export.test.ts | 76 ++++++++++++ .../workspace-transfer-files.test.ts | 6 + QuickShell.Raycast/src/lib/import-export.ts | 67 +++++++++++ .../src/lib/workspace-transfer-files.ts | 109 ++++++++++++------ QuickShell.Raycast/src/open-workspace.tsx | 18 ++- 5 files changed, 236 insertions(+), 40 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/import-export.test.ts b/QuickShell.Raycast/src/__tests__/import-export.test.ts index 05a7d68c..ea0a4474 100644 --- a/QuickShell.Raycast/src/__tests__/import-export.test.ts +++ b/QuickShell.Raycast/src/__tests__/import-export.test.ts @@ -110,4 +110,80 @@ 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("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("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__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index 79ae562a..5f3e97c0 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_EXPORT_FILE_NAME, + buildWindowsTransferPowerShell, readWorkspaceImportFile, writeWorkspaceExportFile, } from "../lib/workspace-transfer-files"; @@ -28,4 +29,9 @@ 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')"); + }); }); diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index f4e1b005..34bdae6d 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -78,6 +78,11 @@ 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)) { + 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 +90,68 @@ 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 layoutHints: Array<{ kind: "workspace" } | { kind: "separator"; title: string | null }> = []; + + 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; + layoutHints.push({ kind: "separator", title }); + continue; + } + + const payload = record.workspace && typeof record.workspace === "object" ? record.workspace : record; + if (!payload || typeof payload !== "object") { + continue; + } + const workspaceRecord = 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; + } + + workspaces.push(payload); + layoutHints.push({ kind: "workspace" }); + } + + if (workspaces.length === 0) { + throw new Error("No workspaces found in import file."); + } + + const result = importShortcutArray(workspaces, existing); + if (!existing || existing.workspaces.length === 0) { + const layoutEntries: LayoutEntry[] = []; + let workspaceIndex = 0; + const importedWorkspaces = result.data.workspaces; + for (const hint of layoutHints) { + if (hint.kind === "separator") { + layoutEntries.push({ type: "separator", id: createStableId(), title: hint.title }); + continue; + } + const workspace = importedWorkspaces[workspaceIndex]; + workspaceIndex += 1; + if (workspace) { + layoutEntries.push({ type: "workspace", workspaceId: workspace.id }); + } + } + result.data.layoutEntries = layoutEntries; + } + + return result; +} + function importShortcutArray(items: unknown[], existing?: StoredData): ImportResult { const normalizedItems = items .map((item) => normalizeRecordKeys(item)) diff --git a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts index 0b2ad009..08c8e89d 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,41 +27,68 @@ export function pickWorkspaceTransferJsonPath(kind: DialogKind): string | null { return null; } -function pickWindowsTransferJsonPath(kind: DialogKind): string | null { - const script = - 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 (*.*)|*.*'", - `$d.FileName = '${DEFAULT_EXPORT_FILE_NAME}'`, - "$d.DefaultExt = 'json'", - "$d.AddExtension = $true", - "$d.OverwritePrompt = $true", - "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }", - ].join("; ") - : [ - "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("; "); +/** Builds the PowerShell script for Windows file dialogs. Exported for unit tests. */ +export function buildWindowsTransferPowerShell(kind: DialogKind): string { + const initialDirectory = "[Environment]::GetFolderPath('Desktop')"; + if (kind === "save") { + return [ + "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 (*.*)|*.*'", + `$d.FileName = '${DEFAULT_EXPORT_FILE_NAME}'`, + "$d.DefaultExt = 'json'", + "$d.AddExtension = $true", + "$d.OverwritePrompt = $true", + `$d.InitialDirectory = ${initialDirectory}`, + "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }", + ].join("; "); + } - 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; - } catch { - return null; + return [ + "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", + `$d.InitialDirectory = ${initialDirectory}`, + "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }", + ].join("; "); +} + +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, + }, + ); + const selected = stdout.trim(); + return selected.length > 0 ? path.resolve(selected) : null; + } catch (error) { + // pwsh may be missing; fall through to Windows PowerShell 5.1. + if (shell === "powershell.exe") { + return null; + } + const errno = (error as NodeJS.ErrnoException | undefined)?.code; + if (errno !== "ENOENT") { + // Dialog cancel often surfaces as a non-zero exit with empty stdout; treat as cancel. + return null; + } + } } + + return null; } /** Exported for unit tests. */ @@ -73,13 +106,13 @@ export function buildMacTransferOsascript(kind: DialogKind): string { ].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(); + const selected = stdout.trim(); return selected.length > 0 ? path.resolve(selected) : null; } catch { return null; diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index bbfaa685..133a8044 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -530,11 +530,18 @@ 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) { + loading.hide(); return; } + loading.title = "Exporting…"; const json = await storage.exportJson(); writeWorkspaceExportFile(filePath, json); await showToast({ @@ -554,11 +561,18 @@ export default function OpenWorkspaceCommand({ } 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) { + loading.hide(); return; } + loading.title = "Importing…"; const trimmed = readWorkspaceImportFile(filePath).trim(); if (!trimmed) { await showToast({ From d75b0818af22ba63332fd180324832f0aaf16b42 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 20:25:20 -0700 Subject: [PATCH 07/24] fix(raycast): validate restoreFromBackup shape and do not default missing backup security to trusted --- .../src/__tests__/storage.test.ts | 57 +++++++++++++++++++ QuickShell.Raycast/src/lib/migration.ts | 17 +++++- QuickShell.Raycast/src/lib/storage.ts | 16 +++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index d9df83da..62942f8a 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -512,6 +512,63 @@ describe("storage", () => { 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 { diff --git a/QuickShell.Raycast/src/lib/migration.ts b/QuickShell.Raycast/src/lib/migration.ts index 39e23bdc..2c98210b 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); diff --git a/QuickShell.Raycast/src/lib/storage.ts b/QuickShell.Raycast/src/lib/storage.ts index 45e3f8e0..3500d73e 100644 --- a/QuickShell.Raycast/src/lib/storage.ts +++ b/QuickShell.Raycast/src/lib/storage.ts @@ -190,7 +190,21 @@ export class QuickShellStorage { }; } - const restored = migrateStoredData(parsed); + 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 }); await this.saveUnlocked(restored, { preserveSecurity: false, allowSubmittedSecurity: true }); return { success: true, From 41fe06eafa47d930e695ca90add6a741f3a33ede Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 20:33:14 -0700 Subject: [PATCH 08/24] Restore Raycast focus after import/export file dialogs. WinForms and osascript steal the foreground; activate Raycast again when the picker closes so the Quick Shell screen resurfaces for toasts and confirmations. Co-authored-by: Cursor --- .../workspace-transfer-files.test.ts | 8 ++ .../src/lib/workspace-transfer-files.ts | 105 +++++++++++++----- 2 files changed, 88 insertions(+), 25 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index 5f3e97c0..ee00e3f7 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_EXPORT_FILE_NAME, + buildMacTransferOsascript, buildWindowsTransferPowerShell, readWorkspaceImportFile, writeWorkspaceExportFile, @@ -34,4 +35,11 @@ describe("workspace-transfer-files", () => { expect(buildWindowsTransferPowerShell("open")).toContain("GetFolderPath('Desktop')"); expect(buildWindowsTransferPowerShell("save")).toContain("GetFolderPath('Desktop')"); }); + + it("reactivates Raycast after Windows and macOS file dialogs", () => { + expect(buildWindowsTransferPowerShell("open")).toContain("AppActivate"); + expect(buildWindowsTransferPowerShell("save")).toContain("AppActivate"); + expect(buildMacTransferOsascript("open")).toContain('tell application "Raycast" to activate'); + expect(buildMacTransferOsascript("save")).toContain('tell application "Raycast" to activate'); + }); }); diff --git a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts index 08c8e89d..168e17c7 100644 --- a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts +++ b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts @@ -30,33 +30,50 @@ export async function pickWorkspaceTransferJsonPath(kind: DialogKind): Promise { const script = buildWindowsTransferPowerShell(kind); const shells = ["pwsh", "powershell.exe"]; @@ -73,9 +90,12 @@ async function pickWindowsTransferJsonPath(kind: DialogKind): Promise 0 ? path.resolve(selected) : null; } catch (error) { + await reactivateRaycastWindow(); // pwsh may be missing; fall through to Windows PowerShell 5.1. if (shell === "powershell.exe") { return null; @@ -91,18 +111,51 @@ async function pickWindowsTransferJsonPath(kind: DialogKind): Promise { + try { + 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 { + // Focus restore is best-effort; never fail the transfer path. + } +} + /** Exported for unit tests. */ export function buildMacTransferOsascript(kind: DialogKind): string { 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", + 'tell application "Raycast" to activate', + "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", + 'tell application "Raycast" to activate', + "return chosenPath", ].join("\n"); } @@ -112,9 +165,11 @@ async function pickMacTransferJsonPath(kind: DialogKind): Promise encoding: "utf8", timeout: 120_000, }); + await reactivateRaycastWindow(); const selected = stdout.trim(); return selected.length > 0 ? path.resolve(selected) : null; } catch { + await reactivateRaycastWindow(); return null; } } From e005e2f296e95730a28120b3d7329f2251b485b4 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 21:00:33 -0700 Subject: [PATCH 09/24] Address PR 133 review follow-ups for import, restore, and dialogs. Keep CmdPal layout rows aligned when duplicates are skipped, hide transfer toasts on cancel, narrow focus-restore error handling, and preserve current settings when restoring a reset backup. Co-authored-by: Cursor --- .../src/__tests__/import-export.test.ts | 41 ++++++++++++++ .../src/__tests__/storage.test.ts | 19 +++++++ .../workspace-transfer-files.test.ts | 20 +++++-- QuickShell.Raycast/src/lib/import-export.ts | 48 ++++++++--------- QuickShell.Raycast/src/lib/storage.ts | 3 ++ .../src/lib/workspace-transfer-files.ts | 14 +++-- QuickShell.Raycast/src/open-workspace.tsx | 54 ++++++++++++++++++- docs/architecture/persistence.md | 2 +- 8 files changed, 164 insertions(+), 37 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/import-export.test.ts b/QuickShell.Raycast/src/__tests__/import-export.test.ts index ea0a4474..d7aab416 100644 --- a/QuickShell.Raycast/src/__tests__/import-export.test.ts +++ b/QuickShell.Raycast/src/__tests__/import-export.test.ts @@ -154,6 +154,47 @@ describe("import-export", () => { ]); }); + 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("imports on-disk CmdPal shortcuts.json Workspace/Security wrappers", () => { const result = importParsedPayload({ version: 1, diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index 62942f8a..e5724a38 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -496,6 +496,25 @@ describe("storage", () => { 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"); diff --git a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index ee00e3f7..1275d90f 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -37,9 +37,21 @@ describe("workspace-transfer-files", () => { }); it("reactivates Raycast after Windows and macOS file dialogs", () => { - expect(buildWindowsTransferPowerShell("open")).toContain("AppActivate"); - expect(buildWindowsTransferPowerShell("save")).toContain("AppActivate"); - expect(buildMacTransferOsascript("open")).toContain('tell application "Raycast" to activate'); - expect(buildMacTransferOsascript("save")).toContain('tell application "Raycast" to activate'); + const windowsOpen = buildWindowsTransferPowerShell("open"); + const windowsSave = buildWindowsTransferPowerShell("save"); + expect(windowsOpen).toContain("AppActivate"); + expect(windowsSave).toContain("AppActivate"); + expect(windowsOpen).toContain("catch [System.Runtime.InteropServices.COMException]"); + expect(windowsOpen).not.toContain("catch {}"); + + const macOpen = buildMacTransferOsascript("open"); + const macSave = buildMacTransferOsascript("save"); + expect(macOpen).toContain('tell application "Raycast" to activate'); + expect(macSave).toContain('tell application "Raycast" to activate'); + // 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"), + ); }); }); diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index 34bdae6d..483e7e8d 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -1,4 +1,4 @@ -import { createStableId } from "./ids"; +import { createStableId, isStableWorkspaceId } from "./ids"; import { migrateStoredData } from "./migration"; import type { LayoutEntry, StoredData, Workspace } from "./schema"; import { createEmptyStoredData } from "./schema"; @@ -96,7 +96,7 @@ export function importParsedPayload(parsed: unknown, existing?: StoredData): Imp */ function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): ImportResult { const workspaces: unknown[] = []; - const layoutHints: Array<{ kind: "workspace" } | { kind: "separator"; title: string | null }> = []; + const layoutEntries: LayoutEntry[] = []; for (const raw of entries) { const entry = normalizeRecordKeys(raw); @@ -107,7 +107,7 @@ function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): 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; - layoutHints.push({ kind: "separator", title }); + layoutEntries.push({ type: "separator", id: createStableId(), title }); continue; } @@ -115,41 +115,31 @@ function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): if (!payload || typeof payload !== "object") { continue; } - const workspaceRecord = payload as UnknownRecord; + 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; } - workspaces.push(payload); - layoutHints.push({ kind: "workspace" }); + // Stable id ties layout rows to merge retention so skipped duplicates do not shift later rows. + const rawId = typeof workspaceRecord.id === "string" ? workspaceRecord.id.trim() : ""; + const workspaceId = isStableWorkspaceId(rawId) ? rawId.toLowerCase() : createStableId(); + workspaces.push({ ...workspaceRecord, id: workspaceId }); + layoutEntries.push({ type: "workspace", workspaceId }); } if (workspaces.length === 0) { throw new Error("No workspaces found in import file."); } - const result = importShortcutArray(workspaces, existing); - if (!existing || existing.workspaces.length === 0) { - const layoutEntries: LayoutEntry[] = []; - let workspaceIndex = 0; - const importedWorkspaces = result.data.workspaces; - for (const hint of layoutHints) { - if (hint.kind === "separator") { - layoutEntries.push({ type: "separator", id: createStableId(), title: hint.title }); - continue; - } - const workspace = importedWorkspaces[workspaceIndex]; - workspaceIndex += 1; - if (workspace) { - layoutEntries.push({ type: "workspace", workspaceId: workspace.id }); - } - } - result.data.layoutEntries = layoutEntries; - } - - return result; + const migrated = migrateStoredData({ + version: 1, + workspaces, + layoutEntries, + settings: existing?.settings ?? createEmptyStoredData().settings, + }); + return mergeImportedData(migrated, existing); } function importShortcutArray(items: unknown[], existing?: StoredData): ImportResult { @@ -252,7 +242,11 @@ function remapImportedLayout(layout: LayoutEntry[] | undefined, idRemap: Map 0 ? warnings.join(" ") : workspace.name, + }); + } + 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; @@ -538,7 +579,6 @@ export default function OpenWorkspaceCommand({ try { const filePath = await pickWorkspaceTransferJsonPath("save"); if (!filePath) { - loading.hide(); return; } loading.title = "Exporting…"; @@ -557,6 +597,8 @@ export default function OpenWorkspaceCommand({ }); } catch (exportError) { await showStorageFailure("Export workspaces", exportError); + } finally { + loading.hide(); } } @@ -569,7 +611,6 @@ export default function OpenWorkspaceCommand({ try { const filePath = await pickWorkspaceTransferJsonPath("open"); if (!filePath) { - loading.hide(); return; } loading.title = "Importing…"; @@ -605,6 +646,8 @@ export default function OpenWorkspaceCommand({ }); } catch (importError) { await showStorageFailure("Import workspaces", importError); + } finally { + loading.hide(); } } @@ -1069,6 +1112,13 @@ export default function OpenWorkspaceCommand({ shortcut={Keyboard.Shortcut.Common.OpenWith} onAction={() => handleOpenFolder(workspace)} /> + {workspaceHasConfiguredCompanions(workspace) ? ( + handleOpenCompanions(workspace)} + /> + ) : null} {workspace.repoUrl ? ( handleOpenUrl(workspace, "repo")} /> ) : null} diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 7ddd1b00..e91dda98 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -153,7 +153,7 @@ Also first-time import from `shortcuts.json.bak` or old product path `TerminalSh **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 the durable backup key into the live store. Corrupt backup JSON is discarded (key cleared) with a clear message rather than hard-failing forever. +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. From 84b8d8f6fd5afda3ae7d9e9424d37e9d23bb5fed Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 22:13:12 -0700 Subject: [PATCH 10/24] Polish Raycast macOS support for Tier A parity. Allow Open Directory on POSIX paths, hide elevation UX on Mac, enable Terminal/iTerm tab multi-launch, expand Mac discover roots, and gate macOS CI with lint and build. Co-authored-by: Cursor --- .github/workflows/ci.yml | 11 +- QuickShell.Raycast/CHANGELOG.md | 6 +- QuickShell.Raycast/README.md | 6 +- QuickShell.Raycast/package.json | 4 +- QuickShell.Raycast/raycast-env.d.ts | 2 +- .../__tests__/extension-preferences.test.ts | 10 +- .../git-repo-search-roots-mac.test.ts | 7 + .../src/__tests__/launch-executor.test.ts | 60 ++++++- .../src/__tests__/mac-launch.test.ts | 120 ++++++++++++++ .../src/__tests__/security.test.ts | 43 ++++- .../src/__tests__/suggest-commands.test.ts | 53 +++++- .../src/__tests__/validation.test.ts | 21 ++- .../__tests__/workspace-form-state.test.ts | 154 +++++++++++------- .../components/discover-git-repos-view.tsx | 26 ++- .../src/components/workspace-form.tsx | 107 ++++++++---- .../src/lib/git-repo-search-roots.ts | 20 +++ QuickShell.Raycast/src/lib/mac-launch.ts | 87 +++++++++- QuickShell.Raycast/src/lib/preferences.ts | 6 +- .../src/lib/project-setup-suggestion.ts | 1 + QuickShell.Raycast/src/lib/schema.ts | 2 +- QuickShell.Raycast/src/lib/security.ts | 29 +++- .../src/lib/suggest-commands.ts | 88 +++++++++- .../src/lib/terminal-options.ts | 3 +- QuickShell.Raycast/src/lib/validation.ts | 4 + .../src/lib/workspace-form-state.ts | 55 ++++++- QuickShell.Raycast/src/open-workspace.tsx | 8 +- docs/architecture/hosts.md | 2 +- docs/architecture/parity-matrix.md | 7 +- 28 files changed, 799 insertions(+), 143 deletions(-) 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/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__/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..c72d06fb 100644 --- a/QuickShell.Raycast/src/__tests__/security.test.ts +++ b/QuickShell.Raycast/src/__tests__/security.test.ts @@ -285,6 +285,39 @@ describe("workspace security policy", () => { expect(effects.warnings).toHaveLength(1); }); + it("includes all configured companions when companionSelection is all", () => { + const content: Workspace = { + ...workspace, + directory: "\\\\wsl$\\Ubuntu\\home\\dev\\project", + 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("fails closed for ambiguous duplicate companion IDs", () => { const duplicate = { id: "duplicate", @@ -326,16 +359,10 @@ 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("matches the shared JSON default", () => { 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/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index e89ff8cb..c471ec7c 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,11 +124,18 @@ 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 } = usePromise(async () => + buildWorkspaceFromRepo(directory, name, remoteUrl), ); + if (!initialWorkspace) { + return ( + + + + ); + } + return ( ); @@ -235,6 +242,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,8 +521,10 @@ export default function WorkspaceForm({ terminal: selectedTerminal?.terminal ?? "default", wtProfile: selectedTerminal?.wtProfile ?? null, isPinned: formValues.isPinned, - runAsAdmin: formValues.runAsAdmin, - launches, + runAsAdmin: isMacPlatform() ? false : formValues.runAsAdmin, + launches: isMacPlatform() + ? launches.map((launch) => ({ ...launch, runAsAdmin: false })) + : launches, companions, devServerUrl: formValues.devServerUrl, openDevServerOnLaunch: formValues.openDevServerOnLaunch, @@ -547,6 +583,7 @@ export default function WorkspaceForm({ runAsAdmin: false, isEnabled: true, label: "Launch", + taskType: "none", }, ]); nameCustomizedRef.current = false; @@ -692,7 +729,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/mac-launch.ts b/QuickShell.Raycast/src/lib/mac-launch.ts index 2365c60b..6f68cbbd 100644 --- a/QuickShell.Raycast/src/lib/mac-launch.ts +++ b/QuickShell.Raycast/src/lib/mac-launch.ts @@ -10,6 +10,11 @@ export type MacLaunchInvocation = { displayName: string; }; +export type MacLaunchEntryGroup = { + hostId: MacTerminalHostId; + entries: LaunchPlanEntry[]; +}; + const TERMINAL_APP_CANDIDATES = ["/System/Applications/Utilities/Terminal.app", "/Applications/Utilities/Terminal.app"]; const ITERM_APP_CANDIDATES = ["/Applications/iTerm.app", "/Applications/iTerm2.app"]; @@ -67,6 +72,39 @@ export function buildMacShellCommand(directory: string, command: string | null | return `${cd} && ${trimmed}`; } +/** + * Group Mac launches by terminal host. When `separateWindows` is false, compatible + * entries share one Terminal.app / iTerm window as tabs. Elevation is ignored on Mac. + */ +export function groupMacLaunchEntries( + entries: LaunchPlanEntry[], + settings: QuickShellSettings, + separateWindows: boolean, +): MacLaunchEntryGroup[] { + if (separateWindows) { + return entries.map((entry) => ({ + hostId: resolveMacTerminalHostId(entry.launch.terminal, settings), + entries: [entry], + })); + } + + const groups: MacLaunchEntryGroup[] = []; + const groupIndexByHost = new Map(); + + for (const entry of entries) { + const hostId = resolveMacTerminalHostId(entry.launch.terminal, settings); + 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 +146,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/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 245b671b..1ac79070 100644 --- a/QuickShell.Raycast/src/lib/schema.ts +++ b/QuickShell.Raycast/src/lib/schema.ts @@ -110,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..a451ae4d 100644 --- a/QuickShell.Raycast/src/lib/security.ts +++ b/QuickShell.Raycast/src/lib/security.ts @@ -298,7 +298,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 +328,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 +474,20 @@ 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; } function resolveExecutablePath(path: string): string | null { 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/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index 0895573e..45064054 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -54,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"; @@ -1069,7 +1069,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} Date: Tue, 28 Jul 2026 22:17:53 -0700 Subject: [PATCH 11/24] Update QuickShell.Raycast/src/open-workspace.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Anthony Thompson --- QuickShell.Raycast/src/open-workspace.tsx | 64 ++++++++++++----------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index 45064054..c3f5ced0 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -464,42 +464,46 @@ export default function OpenWorkspaceCommand({ } async function handleOpenCompanions(workspace: Workspace) { - const stored = await storage.getStoredWorkspace(workspace.id); - if (!stored) { - await showToast({ style: Toast.Style.Failure, title: "Workspace not found" }); - return; - } + 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 configure a valid companion app path.", + const authorizedEffects = authorizePostLaunchEffects(stored, { + includeCompanion: true, + includeDevServer: false, + companionSelection: "all", }); - return; - } + if (authorizedEffects.plan.companions.length === 0) { + await showToast({ + style: Toast.Style.Failure, + title: "Companion apps blocked", + message: authorizedEffects.warnings[0] ?? "Trust this workspace and configure a valid companion app path.", + }); + 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; + } - 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.", + style: Toast.Style.Success, + title: "Companion apps opened", + message: warnings.length > 0 ? warnings.join(" ") : workspace.name, }); - return; + } catch (companionError) { + await showStorageFailure("Open companion apps", companionError); } - - await showToast({ - style: Toast.Style.Success, - title: "Companion apps opened", - message: warnings.length > 0 ? warnings.join(" ") : workspace.name, - }); } async function handleOpenUrl(workspace: Workspace, kind: "repo" | "dev") { From 950a28795a2fce1f437949c41fc53f0234ec2565 Mon Sep 17 00:00:00 2001 From: Anthony Thompson Date: Tue, 28 Jul 2026 22:18:33 -0700 Subject: [PATCH 12/24] Update QuickShell.Raycast/src/lib/import-export.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Anthony Thompson --- QuickShell.Raycast/src/lib/import-export.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index 483e7e8d..65eea136 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -122,9 +122,15 @@ function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): continue; } + const workspaceIds = new Set(); + // Stable id ties layout rows to merge retention so skipped duplicates do not shift later rows. const rawId = typeof workspaceRecord.id === "string" ? workspaceRecord.id.trim() : ""; - const workspaceId = isStableWorkspaceId(rawId) ? rawId.toLowerCase() : createStableId(); + let workspaceId = isStableWorkspaceId(rawId) ? rawId.toLowerCase() : createStableId(); + while (workspaceIds.has(workspaceId)) { + workspaceId = createStableId(); + } + workspaceIds.add(workspaceId); workspaces.push({ ...workspaceRecord, id: workspaceId }); layoutEntries.push({ type: "workspace", workspaceId }); } From 364ecd98574247749e005b46c7e8d6a2799a3b1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:22:42 +0000 Subject: [PATCH 13/24] Address Qodo and CodeRabbit review feedback on PR 133. Deduplicate CmdPal source IDs in layout import, write transfer paths before focus restore, keep dialog stdout on shell failure, wrap Open Companions errors, and add regression tests for nested locks and Windows dialog fallbacks. Co-authored-by: Anthony Thompson --- .../src/__tests__/import-export.test.ts | 28 ++++ .../src/__tests__/storage.test.ts | 12 ++ .../workspace-transfer-files.test.ts | 128 +++++++++++++++++- QuickShell.Raycast/src/lib/import-export.ts | 10 ++ .../src/lib/workspace-transfer-files.ts | 56 ++++++-- 5 files changed, 217 insertions(+), 17 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/import-export.test.ts b/QuickShell.Raycast/src/__tests__/import-export.test.ts index d7aab416..9f046b97 100644 --- a/QuickShell.Raycast/src/__tests__/import-export.test.ts +++ b/QuickShell.Raycast/src/__tests__/import-export.test.ts @@ -195,6 +195,34 @@ describe("import-export", () => { ]); }); + 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, diff --git a/QuickShell.Raycast/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index e5724a38..1e0004b5 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -613,4 +613,16 @@ describe("storage", () => { 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__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index 1275d90f..a490dd83 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -1,15 +1,51 @@ +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(() => { + 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[] = []; @@ -17,6 +53,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", () => { @@ -36,22 +73,107 @@ describe("workspace-transfer-files", () => { 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"); - expect(windowsOpen).toContain("catch [System.Runtime.InteropServices.COMException]"); - expect(windowsOpen).not.toContain("catch {}"); 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)); + }); }); diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index 65eea136..c9abde40 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -97,6 +97,8 @@ export function importParsedPayload(parsed: unknown, existing?: StoredData): Imp 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); @@ -125,12 +127,20 @@ function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): const workspaceIds = new Set(); // 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(); +<<<<<<< HEAD while (workspaceIds.has(workspaceId)) { workspaceId = createStableId(); } workspaceIds.add(workspaceId); +======= + if (usedEnvelopeIds.has(workspaceId)) { + workspaceId = createStableId(); + } + usedEnvelopeIds.add(workspaceId); +>>>>>>> 94035b0 (Address Qodo and CodeRabbit review feedback on PR 133.) workspaces.push({ ...workspaceRecord, id: workspaceId }); layoutEntries.push({ type: "workspace", workspaceId }); } diff --git a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts index c952d6d3..94d61542 100644 --- a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts +++ b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts @@ -51,15 +51,15 @@ export function buildWindowsTransferPowerShell(kind: DialogKind): string { `$d.InitialDirectory = ${initialDirectory}`, ]; - // WinForms dialogs steal foreground focus; restore Raycast before returning so the - // extension UI is visible again for follow-up toasts / confirmations. + // 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 }", - reactivateRaycastPowerShellSnippet(), "if ($selected) { [Console]::Out.Write($selected) }", + reactivateRaycastPowerShellSnippet(), ].join("; "); } @@ -77,6 +77,12 @@ export function reactivateRaycastPowerShellSnippet(): string { ].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"]; @@ -95,16 +101,22 @@ async function pickWindowsTransferJsonPath(kind: DialogKind): Promise 0 ? path.resolve(selected) : null; + 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; + } // pwsh may be missing; fall through to Windows PowerShell 5.1. if (shell === "powershell.exe") { + console.error("Windows transfer dialog script failed:", error); return null; } const errno = (error as NodeJS.ErrnoException | undefined)?.code; if (errno !== "ENOENT") { + console.error("Windows transfer dialog script failed:", error); // Dialog cancel often surfaces as a non-zero exit with empty stdout; treat as cancel. return null; } @@ -135,8 +147,25 @@ export async function reactivateRaycastWindow(): Promise { } } +/** + * 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}"`, @@ -147,9 +176,7 @@ export function buildMacTransferOsascript(kind: DialogKind): string { ' set chosenPath to ""', "end try", // Focus restore is best-effort and must not discard a valid selection. - "try", - ' tell application "Raycast" to activate', - "end try", + ...activateBlock, "return chosenPath", ].join("\n"); } @@ -160,9 +187,7 @@ export function buildMacTransferOsascript(kind: DialogKind): string { "on error", ' set chosenPath to ""', "end try", - "try", - ' tell application "Raycast" to activate', - "end try", + ...activateBlock, "return chosenPath", ].join("\n"); } @@ -174,10 +199,13 @@ async function pickMacTransferJsonPath(kind: DialogKind): Promise timeout: 120_000, }); await reactivateRaycastWindow(); - const selected = stdout.trim(); - return selected.length > 0 ? path.resolve(selected) : null; - } catch { + return selectedPathFromDialogStdout(stdout); + } catch (error) { await reactivateRaycastWindow(); + const fromStdout = selectedPathFromDialogStdout((error as { stdout?: string } | undefined)?.stdout); + if (fromStdout) { + return fromStdout; + } return null; } } From 1c2b479daa8cb34fd3713daf99c6b0aa91650cb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:24:43 +0000 Subject: [PATCH 14/24] Block companion launch when the local workspace directory is missing. Authorize companion effects with the same DirectoryMissing gate as terminal launches (WSL UNC still skipped). Narrow Open Companions try/catch to process spawn only, matching Open Folder. Co-authored-by: Anthony Thompson --- .../src/__tests__/security.test.ts | 27 +++++++++++++++++++ QuickShell.Raycast/src/lib/security.ts | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/QuickShell.Raycast/src/__tests__/security.test.ts b/QuickShell.Raycast/src/__tests__/security.test.ts index c72d06fb..9ff80e7d 100644 --- a/QuickShell.Raycast/src/__tests__/security.test.ts +++ b/QuickShell.Raycast/src/__tests__/security.test.ts @@ -318,6 +318,33 @@ describe("workspace security policy", () => { 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("fails closed for ambiguous duplicate companion IDs", () => { const duplicate = { id: "duplicate", diff --git a/QuickShell.Raycast/src/lib/security.ts b/QuickShell.Raycast/src/lib/security.ts index a451ae4d..9c6557ce 100644 --- a/QuickShell.Raycast/src/lib/security.ts +++ b/QuickShell.Raycast/src/lib/security.ts @@ -175,7 +175,10 @@ export function authorize( }); } else if ( directory && - (request.kind === "terminal" || request.kind === "launchEntry" || request.kind === "grantTrust") && + (request.kind === "terminal" || + request.kind === "launchEntry" || + request.kind === "grantTrust" || + request.kind === "companion") && isLocalDirectory(directory) && !existsSync(directory) ) { From 99eb9666102e0dc942097600a413887fd2cd9429 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:24:59 +0000 Subject: [PATCH 15/24] Fix leftover merge conflict markers and narrow Open Companions catch. Keep envelope-wide usedEnvelopeIds for duplicate CmdPal source ids, and only wrap companion process spawn in try/catch like Open Folder. Co-authored-by: Anthony Thompson --- QuickShell.Raycast/src/lib/import-export.ts | 9 ------ QuickShell.Raycast/src/open-workspace.tsx | 36 ++++++++++----------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index c9abde40..7e92abce 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -124,23 +124,14 @@ function importCmdPalLayoutEnvelope(entries: unknown[], existing?: StoredData): continue; } - const workspaceIds = new Set(); - // 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(); -<<<<<<< HEAD - while (workspaceIds.has(workspaceId)) { - workspaceId = createStableId(); - } - workspaceIds.add(workspaceId); -======= if (usedEnvelopeIds.has(workspaceId)) { workspaceId = createStableId(); } usedEnvelopeIds.add(workspaceId); ->>>>>>> 94035b0 (Address Qodo and CodeRabbit review feedback on PR 133.) workspaces.push({ ...workspaceRecord, id: workspaceId }); layoutEntries.push({ type: "workspace", workspaceId }); } diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index c3f5ced0..a41cea59 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -464,27 +464,27 @@ 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 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", + 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.", }); - if (authorizedEffects.plan.companions.length === 0) { - await showToast({ - style: Toast.Style.Failure, - title: "Companion apps blocked", - message: authorizedEffects.warnings[0] ?? "Trust this workspace and configure a valid companion app path.", - }); - return; - } + return; + } + try { const result = await runPostLaunchActions(authorizedEffects.plan, { phase: "companions" }); const warnings = [...authorizedEffects.warnings, ...result.warnings]; if (!result.companionOpened) { From 70af5fa62ad8b70515ef80f8aad728886c73eb5d Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 22:26:19 -0700 Subject: [PATCH 16/24] Fix Raycast merge import separators and pwsh dialog fallback. Preserve CmdPal layout separators when merging into existing workspaces, and always try Windows PowerShell 5.1 when pwsh fails for any reason other than a successful cancel. Co-authored-by: Cursor --- .../src/__tests__/import-export.test.ts | 54 +++++++++++++++++++ .../workspace-transfer-files.test.ts | 37 +++++++++++++ QuickShell.Raycast/src/lib/import-export.ts | 9 +++- .../src/lib/workspace-transfer-files.ts | 10 ++-- 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/import-export.test.ts b/QuickShell.Raycast/src/__tests__/import-export.test.ts index 9f046b97..d2c8c0dd 100644 --- a/QuickShell.Raycast/src/__tests__/import-export.test.ts +++ b/QuickShell.Raycast/src/__tests__/import-export.test.ts @@ -111,6 +111,60 @@ describe("import-export", () => { 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, diff --git a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index a490dd83..e5d8eec3 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -176,4 +176,41 @@ describe("workspace-transfer-files", () => { 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/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index 7e92abce..e284f1ab 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -206,11 +206,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 to + // appending workspace rows when the payload had no layout entries. + ...(remappedImportLayout.length > 0 + ? remappedImportLayout + : newlyImported.map((workspace) => ({ type: "workspace" as const, workspaceId: workspace.id }))), ]; return { diff --git a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts index 94d61542..9e0af5ed 100644 --- a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts +++ b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts @@ -109,17 +109,13 @@ async function pickWindowsTransferJsonPath(kind: DialogKind): Promise Date: Tue, 28 Jul 2026 22:36:36 -0700 Subject: [PATCH 17/24] style: fix IDE lint warnings in markdown docs and Raycast source --- AGENTS.md | 27 +++--- .../src/__tests__/storage.test.ts | 6 +- .../workspace-transfer-files.test.ts | 1 + .../src/components/workspace-form.tsx | 4 +- QuickShell.Raycast/src/lib/migration.ts | 9 +- QuickShell.Raycast/src/open-workspace.tsx | 3 +- docs/architecture/hosts.md | 80 ++++++++-------- docs/architecture/persistence.md | 92 +++++++++---------- 8 files changed, 112 insertions(+), 110 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3239d02..8bf4753a 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 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. 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. @@ -21,6 +21,7 @@ The UI term is **workspace**; the on-disk file is still `%LOCALAPPDATA%\QuickShe **Typed command routing.** `CommandRouter` (`CommandRouting/CommandRouter.cs`) uses `ICommandIdParser` to parse a deep-link string into a `CommandDescriptor` record (`Id, Kind, WorkspaceId, LaunchId, Directory, Branch`), then dispatches by `CommandKind` to a registered `ICommandItemHandler` (`CommandRouting/ICommandItemHandler.cs` + `CommandItemHandlers.cs`). Handlers receive a `QuickShellPageContext` (Shortcuts, Settings, CreateShortcut, ReloadPages) and return an `ICommandItem` (often a Page). To add a new deep link: add a `CommandKind` + an `ICommandItemHandler`. **User command to launch flow.** + 1. CmdPal invokes `GetCommandItem(id)` (deep link) or shows `TopLevelCommands()`. 2. `ICommandRouter` -> `ICommandIdParser.TryParse` -> `CommandDescriptor`. 3. Matching `ICommandItemHandler.Create` builds a `CommandItem`/`Page`. @@ -35,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 @@ -120,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/src/__tests__/storage.test.ts b/QuickShell.Raycast/src/__tests__/storage.test.ts index 1e0004b5..f178d2fd 100644 --- a/QuickShell.Raycast/src/__tests__/storage.test.ts +++ b/QuickShell.Raycast/src/__tests__/storage.test.ts @@ -621,8 +621,8 @@ describe("storage", () => { }; const locked = storage as unknown as LockHost; - await expect( - locked.withWriteLock(async () => locked.withWriteLock(async () => "nested")), - ).rejects.toThrow(/Nested QuickShellStorage write lock/); + await expect(locked.withWriteLock(async () => locked.withWriteLock(async () => "nested"))).rejects.toThrow( + /Nested QuickShellStorage write lock/, + ); }); }); diff --git a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts index e5d8eec3..994f1fd8 100644 --- a/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts +++ b/QuickShell.Raycast/src/__tests__/workspace-transfer-files.test.ts @@ -14,6 +14,7 @@ import { } 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. diff --git a/QuickShell.Raycast/src/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index 30b733eb..3a535811 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -522,9 +522,7 @@ export default function WorkspaceForm({ wtProfile: selectedTerminal?.wtProfile ?? null, isPinned: formValues.isPinned, runAsAdmin: isMacPlatform() ? false : formValues.runAsAdmin, - launches: isMacPlatform() - ? launches.map((launch) => ({ ...launch, runAsAdmin: false })) - : launches, + launches: isMacPlatform() ? launches.map((launch) => ({ ...launch, runAsAdmin: false })) : launches, companions, devServerUrl: formValues.devServerUrl, openDevServerOnLaunch: formValues.openDevServerOnLaunch, diff --git a/QuickShell.Raycast/src/lib/migration.ts b/QuickShell.Raycast/src/lib/migration.ts index 2c98210b..f3617546 100644 --- a/QuickShell.Raycast/src/lib/migration.ts +++ b/QuickShell.Raycast/src/lib/migration.ts @@ -167,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/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index a41cea59..cad897c7 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -479,7 +479,8 @@ export default function OpenWorkspaceCommand({ 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.", + message: + authorizedEffects.warnings[0] ?? "Trust this workspace and use a valid local folder with a companion app.", }); return; } diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index 65dc932d..6c27c3d6 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` (`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 / 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 / sideload | 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,16 +53,16 @@ src/ Important libs: -| 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 | +| 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). @@ -74,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/persistence.md b/docs/architecture/persistence.md index e91dda98..8df63fb4 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 @@ -73,16 +73,16 @@ on failure → RestoreLastGoodLayout Used by Upsert, Delete, pin, Undo/Redo, Import, Reset: -1. Normalize + serialize envelope -2. **`WriteLayoutAtomic`** -3. Update indexes, `_lastGoodLayout`, mtime +1. Normalize + serialize envelope +2. **`WriteLayoutAtomic`** +3. Update indexes, `_lastGoodLayout`, mtime 4. Raise **`WorkspacesChanged`** ### Atomic writer `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,28 +124,28 @@ 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` (`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) | +| 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. @@ -159,14 +159,14 @@ UI: Transfer actions on the workspaces hub (`open-workspace.tsx`) — confirmed ## 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 @@ -174,13 +174,13 @@ UI: Transfer actions on the workspaces hub (`open-workspace.tsx`) — confirmed ## 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 From d96545bc1b19d39f0b7cc55c148ba204ce89e396 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:40:07 +0000 Subject: [PATCH 18/24] fix(raycast): toast discover build failures and log macOS dialog errors Surface ReviewWorkspaceForm usePromise rejections with useLoadErrorToast and an error EmptyView so failed workspace builds do not hang on loading. Log genuine osascript failures in pickMacTransferJsonPath with console.error while keeping stdout-based cancellation handling. Co-authored-by: Anthony Thompson --- .../src/components/discover-git-repos-view.tsx | 12 +++++++++++- .../src/lib/workspace-transfer-files.ts | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index c471ec7c..6f7dc7ac 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -124,10 +124,20 @@ function buildLightWorkspaceFromRepo(directory: string, name: string, remoteUrl? } function ReviewWorkspaceForm({ directory, name, remoteUrl, onCreated }: ReviewWorkspaceFormProps) { - const { data: initialWorkspace, isLoading } = usePromise(async () => + const { data: initialWorkspace, isLoading, error } = usePromise(async () => buildWorkspaceFromRepo(directory, name, remoteUrl), ); + useLoadErrorToast(error, "Failed to prepare workspace"); + + if (error) { + return ( + + + + ); + } + if (!initialWorkspace) { return ( diff --git a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts index 9e0af5ed..9fc2a3fa 100644 --- a/QuickShell.Raycast/src/lib/workspace-transfer-files.ts +++ b/QuickShell.Raycast/src/lib/workspace-transfer-files.ts @@ -202,6 +202,7 @@ async function pickMacTransferJsonPath(kind: DialogKind): Promise if (fromStdout) { return fromStdout; } + console.error("macOS transfer dialog script failed:", error); return null; } } From ce4051602702a9753d98152089d986ff6fa8be4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:50:26 +0000 Subject: [PATCH 19/24] fix(raycast): prettier-format ReviewWorkspaceForm usePromise destructure macOS CI ray lint failed on discover-git-repos-view.tsx Prettier style after the usePromise error toast change. Apply the project Prettier layout. Co-authored-by: Anthony Thompson --- .../src/components/discover-git-repos-view.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index 6f7dc7ac..f1cf2eb9 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -124,9 +124,11 @@ function buildLightWorkspaceFromRepo(directory: string, name: string, remoteUrl? } function ReviewWorkspaceForm({ directory, name, remoteUrl, onCreated }: ReviewWorkspaceFormProps) { - const { data: initialWorkspace, isLoading, error } = usePromise(async () => - buildWorkspaceFromRepo(directory, name, remoteUrl), - ); + const { + data: initialWorkspace, + isLoading, + error, + } = usePromise(async () => buildWorkspaceFromRepo(directory, name, remoteUrl)); useLoadErrorToast(error, "Failed to prepare workspace"); From c9fa50db0f28059b69a0588a3d89c4b1491bd99f Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 23:02:32 -0700 Subject: [PATCH 20/24] Address CodeRabbit and Qodo review feedback on PR 133. Preserve macOS elevation metadata on save, reject future CmdPal envelope versions, keep separator-only merge layouts from appending phantom workspaces, fix Mac same-as-previous grouping, require existing dirs for companion WSL paths, and tighten Open Companion / reset toast handling. Co-authored-by: Cursor --- .../src/__tests__/security.test.ts | 54 +++++++++++++++++-- .../src/__tests__/single-row-launch.test.ts | 17 +++++- .../src/components/workspace-form.tsx | 5 +- QuickShell.Raycast/src/lib/import-export.ts | 11 ++-- QuickShell.Raycast/src/lib/mac-launch.ts | 9 +++- QuickShell.Raycast/src/lib/security.ts | 22 +++++++- QuickShell.Raycast/src/open-workspace.tsx | 40 +++++++------- docs/architecture/hosts.md | 2 +- 8 files changed, 124 insertions(+), 36 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/security.test.ts b/QuickShell.Raycast/src/__tests__/security.test.ts index 9ff80e7d..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: [ @@ -286,9 +287,10 @@ describe("workspace security policy", () => { }); it("includes all configured companions when companionSelection is all", () => { + const directory = createTempDirectory(); const content: Workspace = { ...workspace, - directory: "\\\\wsl$\\Ubuntu\\home\\dev\\project", + directory, companionApps: [ { id: "on-launch", @@ -345,7 +347,28 @@ describe("workspace security policy", () => { 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, @@ -355,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 }; @@ -392,6 +415,31 @@ describe("workspace trust kill switch (disabled)", () => { 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", () => { const shared = JSON.parse( readFileSync(resolve(__dirname, "../../../shared/workspace-trust-features.json"), "utf8"), 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/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index 3a535811..6862c230 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -521,8 +521,9 @@ export default function WorkspaceForm({ terminal: selectedTerminal?.terminal ?? "default", wtProfile: selectedTerminal?.wtProfile ?? null, isPinned: formValues.isPinned, - runAsAdmin: isMacPlatform() ? false : formValues.runAsAdmin, - launches: isMacPlatform() ? launches.map((launch) => ({ ...launch, runAsAdmin: false })) : launches, + runAsAdmin: isMacPlatform() ? initialWorkspace.runAsAdmin : formValues.runAsAdmin, + // Keep elevation metadata on macOS saves so Windows re-import still elevates. + launches, companions, devServerUrl: formValues.devServerUrl, openDevServerOnLaunch: formValues.openDevServerOnLaunch, diff --git a/QuickShell.Raycast/src/lib/import-export.ts b/QuickShell.Raycast/src/lib/import-export.ts index e284f1ab..3b3d88f2 100644 --- a/QuickShell.Raycast/src/lib/import-export.ts +++ b/QuickShell.Raycast/src/lib/import-export.ts @@ -1,7 +1,7 @@ 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; @@ -80,6 +80,9 @@ export function importParsedPayload(parsed: unknown, existing?: StoredData): Imp // 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); } @@ -211,9 +214,9 @@ function mergeImportedData(imported: StoredData, existing?: StoredData): ImportR ? remappedImportLayout : [ ...(base.layoutEntries ?? []), - // Prefer remapped imported layout (keeps CmdPal separators). Fall back to - // appending workspace rows when the payload had no layout entries. - ...(remappedImportLayout.length > 0 + // 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 }))), ]; diff --git a/QuickShell.Raycast/src/lib/mac-launch.ts b/QuickShell.Raycast/src/lib/mac-launch.ts index 6f68cbbd..f0f5e694 100644 --- a/QuickShell.Raycast/src/lib/mac-launch.ts +++ b/QuickShell.Raycast/src/lib/mac-launch.ts @@ -10,7 +10,7 @@ export type MacLaunchInvocation = { displayName: string; }; -export type MacLaunchEntryGroup = { +type MacLaunchEntryGroup = { hostId: MacTerminalHostId; entries: LaunchPlanEntry[]; }; @@ -90,9 +90,14 @@ export function groupMacLaunchEntries( const groups: MacLaunchEntryGroup[] = []; const groupIndexByHost = new Map(); + let previousHostId: MacTerminalHostId | undefined; for (const entry of entries) { - const hostId = resolveMacTerminalHostId(entry.launch.terminal, settings); + 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); diff --git a/QuickShell.Raycast/src/lib/security.ts b/QuickShell.Raycast/src/lib/security.ts index 9c6557ce..85c41796 100644 --- a/QuickShell.Raycast/src/lib/security.ts +++ b/QuickShell.Raycast/src/lib/security.ts @@ -179,8 +179,7 @@ export function authorize( request.kind === "launchEntry" || request.kind === "grantTrust" || request.kind === "companion") && - isLocalDirectory(directory) && - !existsSync(directory) + isMissingRequiredDirectory(directory, request.kind) ) { issues.push({ code: "DirectoryMissing", message: "Workspace directory does not exist.", blocking: true }); } @@ -493,6 +492,25 @@ function isLocalDirectory(directory: string): boolean { 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 { const trimmed = path.trim(); if (!trimmed) { diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index cad897c7..c2b62bfa 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -464,28 +464,28 @@ export default function OpenWorkspaceCommand({ } async function handleOpenCompanions(workspace: Workspace) { - const stored = await storage.getStoredWorkspace(workspace.id); - if (!stored) { - await showToast({ style: Toast.Style.Failure, title: "Workspace not found" }); - return; - } + 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.", + const authorizedEffects = authorizePostLaunchEffects(stored, { + includeCompanion: true, + includeDevServer: false, + companionSelection: "all", }); - return; - } + 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; + } - try { const result = await runPostLaunchActions(authorizedEffects.plan, { phase: "companions" }); const warnings = [...authorizedEffects.warnings, ...result.warnings]; if (!result.companionOpened) { @@ -680,7 +680,7 @@ export default function OpenWorkspaceCommand({ await revalidate(); const isNoop = result.outcome === "noop"; await showToast({ - style: Toast.Style.Success, + style: isNoop ? Toast.Style.Animated : Toast.Style.Success, title: isNoop ? "Nothing to reset" : "Workspaces reset", message: result.message, }); diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index 6c27c3d6..82d0f52b 100644 --- a/docs/architecture/hosts.md +++ b/docs/architecture/hosts.md @@ -16,7 +16,7 @@ All hosts retain workspace IDs rather than trust-bearing snapshots and resolve c | **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 | +| **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. From 1317fbfdaf6bb95a1be00fc0c80bfaa9069d6e63 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Tue, 28 Jul 2026 23:16:26 -0700 Subject: [PATCH 21/24] fix(raycast): use terminal toast for no-op reset Animated style left a forever spinner when resetAll returned outcome noop. Co-authored-by: Cursor --- QuickShell.Raycast/src/open-workspace.tsx | 3 ++- docs/architecture/persistence.md | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index c2b62bfa..0b5d2d81 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -680,7 +680,8 @@ export default function OpenWorkspaceCommand({ await revalidate(); const isNoop = result.outcome === "noop"; await showToast({ - style: isNoop ? Toast.Style.Animated : Toast.Style.Success, + // No-op is terminal; Animated would leave a forever spinner. + style: Toast.Style.Success, title: isNoop ? "Nothing to reset" : "Workspaces reset", message: result.message, }); diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 8df63fb4..bed4acbe 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -73,9 +73,9 @@ on failure → RestoreLastGoodLayout Used by Upsert, Delete, pin, Undo/Redo, Import, Reset: -1. Normalize + serialize envelope -2. **`WriteLayoutAtomic`** -3. Update indexes, `_lastGoodLayout`, mtime +1. Normalize + serialize envelope +2. **`WriteLayoutAtomic`** +3. Update indexes, `_lastGoodLayout`, mtime 4. Raise **`WorkspacesChanged`** ### Atomic writer From e7383cca8d6b0dc17527f94c6e8e69c35c0d7e52 Mon Sep 17 00:00:00 2001 From: Anthony Thompson Date: Tue, 28 Jul 2026 23:21:54 -0700 Subject: [PATCH 22/24] Update AGENTS.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Anthony Thompson --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8bf4753a..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. From ac2d94ca65e0d0f513f367cdb8df3c5ecd9018d3 Mon Sep 17 00:00:00 2001 From: Anthony Thompson Date: Tue, 28 Jul 2026 23:22:11 -0700 Subject: [PATCH 23/24] Update QuickShell.Raycast/src/lib/mac-launch.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Anthony Thompson --- QuickShell.Raycast/src/lib/mac-launch.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/QuickShell.Raycast/src/lib/mac-launch.ts b/QuickShell.Raycast/src/lib/mac-launch.ts index f0f5e694..ef85e611 100644 --- a/QuickShell.Raycast/src/lib/mac-launch.ts +++ b/QuickShell.Raycast/src/lib/mac-launch.ts @@ -98,6 +98,10 @@ export function groupMacLaunchEntries( ? previousHostId : resolveMacTerminalHostId(entry.launch.terminal, settings); previousHostId = hostId; + if (separateWindows) { + groups.push({ hostId, entries: [entry] }); + continue; + } const existingIndex = groupIndexByHost.get(hostId); if (existingIndex !== undefined) { groups[existingIndex].entries.push(entry); From 74a787b079bb9b1ea30c7fa8ab7899eb5dda0e60 Mon Sep 17 00:00:00 2001 From: Anthony Thompson Date: Tue, 28 Jul 2026 23:24:23 -0700 Subject: [PATCH 24/24] Potential fix for pull request finding 'CodeQL / Useless conditional' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Anthony Thompson --- QuickShell.Raycast/src/lib/mac-launch.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/QuickShell.Raycast/src/lib/mac-launch.ts b/QuickShell.Raycast/src/lib/mac-launch.ts index ef85e611..f0f5e694 100644 --- a/QuickShell.Raycast/src/lib/mac-launch.ts +++ b/QuickShell.Raycast/src/lib/mac-launch.ts @@ -98,10 +98,6 @@ export function groupMacLaunchEntries( ? previousHostId : resolveMacTerminalHostId(entry.launch.terminal, settings); previousHostId = hostId; - if (separateWindows) { - groups.push({ hostId, entries: [entry] }); - continue; - } const existingIndex = groupIndexByHost.get(hostId); if (existingIndex !== undefined) { groups[existingIndex].entries.push(entry);