From 2dae5252d004dfc6bebf99415d3a2fe971bda3a2 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 00:12:17 -0700 Subject: [PATCH 01/15] Improve Raycast discovery and workspace setup UX --- .../discovered-workspace-seed.test.ts | 40 ++++ .../src/__tests__/git-repo-discovery.test.ts | 49 ++++- .../components/discover-git-repos-view.tsx | 179 ++++++++---------- .../src/components/set-target-branch-form.tsx | 56 ++++-- .../src/components/workspace-form.tsx | 51 +++-- .../src/lib/discovered-workspace-seed.ts | 64 +++++++ .../src/lib/git-repo-discovery.ts | 102 +++++++++- QuickShell.Raycast/src/open-workspace.tsx | 2 +- docs/architecture/forms.md | 2 + docs/architecture/git-and-discover.md | 6 +- 10 files changed, 402 insertions(+), 149 deletions(-) create mode 100644 QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts create mode 100644 QuickShell.Raycast/src/lib/discovered-workspace-seed.ts diff --git a/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts b/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts new file mode 100644 index 00000000..01cb1beb --- /dev/null +++ b/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createWorkspaceFromDiscoveredGitRepo } from "../lib/discovered-workspace-seed"; + +describe("createWorkspaceFromDiscoveredGitRepo", () => { + it("preserves commands, companion app, and metadata for a newly discovered repository", () => { + const workspace = createWorkspaceFromDiscoveredGitRepo({ + directory: "D:\\Dev\\Trackdub_Workspace\\Trackdub", + name: "Trackdub", + remoteUrl: "https://github.com/trackdub/trackdub", + devServerUrl: "http://localhost:5173", + tasks: [ + { label: "Dev server", command: "npm run dev", taskType: "frontend" }, + { label: "API", command: "dotnet watch", taskType: "api" }, + ], + companionSeed: { path: "C:\\Apps\\Cursor.exe", arguments: "." }, + }); + + expect(workspace.name).toBe("Trackdub"); + expect(workspace.abbreviation).toBe("trackdub"); + expect(workspace.repoUrl).toBe("https://github.com/trackdub/trackdub"); + expect(workspace.devServerUrl).toBe("http://localhost:5173"); + expect(workspace.launches.map((launch) => launch.command)).toEqual(["npm run dev", "dotnet watch"]); + expect(workspace.launches.map((launch) => launch.taskType)).toEqual(["frontend", "api"]); + expect(workspace.companionApps).toEqual([ + expect.objectContaining({ path: "C:\\Apps\\Cursor.exe", arguments: ".", openOnLaunch: true }), + ]); + }); + + it("keeps a usable blank launch row when no command suggestion is available", () => { + const workspace = createWorkspaceFromDiscoveredGitRepo({ + directory: "D:\\Dev\\empty-repo", + name: "", + tasks: [], + }); + + expect(workspace.name).toBe("empty-repo"); + expect(workspace.launches).toHaveLength(1); + expect(workspace.launches[0].command).toBeNull(); + }); +}); diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index be74ffc8..44b5de79 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { buildSearchRoots, listDefaultRootCandidates, searchRootsFromWorkspaces } from "../lib/git-repo-search-roots"; +import { describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildSearchRoots, + discoverGitReposForQueryAsync, + listDefaultRootCandidates, + searchRootsFromWorkspaces, +} from "../lib/git-repo-discovery"; + +vi.mock("@raycast/utils", () => ({ + withCache: unknown>(fn: T) => fn, +})); /** Windows-path contracts must not depend on the host OS (macOS CI uses darwin). */ const win32Roots = { pathStyle: "win32" as const }; @@ -94,3 +106,36 @@ describe("buildSearchRoots", () => { expect(roots.map((root) => root.toLowerCase())).not.toContain("c:\\users\\tonyt"); }); }); + +describe("discoverGitReposForQueryAsync", () => { + it("bypasses the normal result cap for typed name searches", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-search-")); + try { + for (let index = 0; index < 55; index += 1) { + mkdirSync(path.join(root, `repo-${index.toString().padStart(2, "0")}`, ".git"), { recursive: true }); + } + + const repos = await discoverGitReposForQueryAsync("repo-", [], { rootDirectories: [root], concurrency: 1 }); + + expect(repos).toHaveLength(55); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("finds the containing repository directly from an absolute path", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-path-")); + const repo = path.join(root, "Trackdub"); + const nested = path.join(repo, "src", "features"); + try { + mkdirSync(path.join(repo, ".git"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + + const repos = await discoverGitReposForQueryAsync(nested); + + expect(repos).toEqual([{ directory: repo, name: "Trackdub", remoteUrl: null }]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index f1cf2eb9..df48edec 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -1,22 +1,22 @@ import { Action, ActionPanel, Icon, List, showToast, Toast, useNavigation } from "@raycast/api"; import { usePromise } from "@raycast/utils"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; 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 { createWorkspaceFromDiscoveredGitRepo } from "../lib/discovered-workspace-seed"; import { resolveWorkspaceSetupSuggestions } from "../lib/suggest-commands"; -import { discoverGitReposCached } from "../lib/git-repo-discovery"; +import { + discoverGitReposCached, + discoverGitReposForQueryAsync, + type GitRepoCandidate, +} from "../lib/git-repo-discovery"; import { searchRootsFromWorkspaces } from "../lib/git-repo-search-roots"; -import { deriveAbbreviationFromName, deriveNameFromDirectory } from "../lib/directory-helpers"; -import { tryGetGitRemoteUrl } from "../lib/git-remote-url"; import { getQuickShellStorage } from "../lib/raycast-storage"; import { showStorageFailure } from "../lib/failure-feedback"; import { isSupportedPlatform } from "../lib/platform"; import { useLoadErrorToast } from "../lib/use-load-error-toast"; -import { launchRowsFromSuggestions } from "../lib/workspace-form-state"; -import { normalizeWorkspace } from "../lib/validation"; -import { createStableId } from "../lib/ids"; import type { Workspace } from "../lib/schema"; type ReviewWorkspaceFormProps = { @@ -26,100 +26,16 @@ type ReviewWorkspaceFormProps = { onCreated: (workspace: Workspace) => Promise; }; -/** Full seed for Review: launches, companions, remotes, Suggest.exe or local heuristics. */ +/** Full seed for every repository selected from Discover Git Repos. */ 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) => ({ - id: row.id, - label: row.label, - terminal: row.terminal, - wtProfile: row.wtProfile ?? null, - command: row.command || null, - runAsAdmin: row.runAsAdmin, - isEnabled: row.isEnabled, - order: index, - taskType: row.taskType || "none", - })) - : [ - { - id: createStableId(), - label: "Launch", - terminal: "default" as const, - wtProfile: null, - command: null, - runAsAdmin: false, - isEnabled: true, - order: 0, - taskType: "none" as const, - }, - ]; - const derivedName = name || deriveNameFromDirectory(directory); - const companionSeed = detectCompanionSeed(directory); - const resolvedRemote = remoteUrl ?? tryGetGitRemoteUrl(directory); - return normalizeWorkspace({ - id: createStableId(), - name: derivedName, - abbreviation: deriveAbbreviationFromName(derivedName), + return createWorkspaceFromDiscoveredGitRepo({ directory, - isPinned: false, - pinOrder: null, - lastUsedUtc: null, - terminal: "default", - wtProfile: null, - command: null, - runAsAdmin: false, - repoUrl: resolvedRemote, + name, + remoteUrl, devServerUrl: detectDevServerUrl(directory), - launches: launchEntries, - companionApps: companionSeed - ? [ - { - id: createStableId(), - path: companionSeed.path, - arguments: companionSeed.arguments || null, - openOnLaunch: true, - order: 0, - }, - ] - : [], - }); -} - -/** One-click Quick Add: blank launch row only, no deep project/companion walk. */ -function buildLightWorkspaceFromRepo(directory: string, name: string, remoteUrl?: string | null): Workspace { - const derivedName = name || deriveNameFromDirectory(directory); - const resolvedRemote = remoteUrl ?? tryGetGitRemoteUrl(directory); - return normalizeWorkspace({ - id: createStableId(), - name: derivedName, - abbreviation: deriveAbbreviationFromName(derivedName), - directory, - isPinned: false, - pinOrder: null, - lastUsedUtc: null, - terminal: "default", - wtProfile: null, - command: null, - runAsAdmin: false, - repoUrl: resolvedRemote, - devServerUrl: null, - launches: [ - { - id: createStableId(), - label: "Launch", - terminal: "default", - wtProfile: null, - command: null, - runAsAdmin: false, - isEnabled: true, - order: 0, - taskType: "none", - }, - ], - companionApps: [], + tasks: resolved.tasks, + companionSeed: detectCompanionSeed(directory), }); } @@ -161,6 +77,8 @@ type DiscoverGitReposViewProps = { export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true }: DiscoverGitReposViewProps) { const [searchText, setSearchText] = useState(""); + const [targetedSearch, setTargetedSearch] = useState<{ query: string; repos: GitRepoCandidate[] } | null>(null); + const [targetedLoadingQuery, setTargetedLoadingQuery] = useState(null); const { pop } = useNavigation(); const storage = getQuickShellStorage(); @@ -174,6 +92,46 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true useLoadErrorToast(error, "Failed to scan git repositories"); + useEffect(() => { + const query = searchText.trim(); + if (!query || !data) { + setTargetedSearch(null); + setTargetedLoadingQuery(null); + return; + } + + let cancelled = false; + const timer = setTimeout(() => { + setTargetedLoadingQuery(query); + void (async () => { + try { + const existing = await storage.getWorkspaces(); + const existingDirs = new Set(existing.map((workspace) => workspace.directory.toLowerCase())); + const extraRoots = searchRootsFromWorkspaces(existing.map((workspace) => workspace.directory)); + const repos = (await discoverGitReposForQueryAsync(query, extraRoots)).filter( + (repo) => !existingDirs.has(repo.directory.toLowerCase()), + ); + if (!cancelled) { + setTargetedSearch({ query, repos }); + } + } catch { + if (!cancelled) { + setTargetedSearch({ query, repos: [] }); + } + } finally { + if (!cancelled) { + setTargetedLoadingQuery(null); + } + } + })(); + }, 250); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [data, searchText]); + const filtered = useMemo(() => { if (!data) { return []; @@ -182,13 +140,26 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true if (!query) { return data; } - return data.filter( + const cachedMatches = data.filter( (repo) => repo.name.toLowerCase().includes(query) || repo.directory.toLowerCase().includes(query) || (repo.remoteUrl ?? "").toLowerCase().includes(query), ); - }, [data, searchText]); + const targetedMatches = targetedSearch?.query === searchText.trim() ? targetedSearch.repos : []; + const seen = new Set(cachedMatches.map((repo) => repo.directory.toLowerCase())); + return [ + ...cachedMatches, + ...targetedMatches.filter((repo) => { + const key = repo.directory.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }), + ]; + }, [data, searchText, targetedSearch]); async function finishAdd(workspace: Workspace) { await revalidate(); @@ -200,7 +171,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true async function handleQuickAdd(directory: string, name: string, remoteUrl?: string | null) { try { - const workspace = buildLightWorkspaceFromRepo(directory, name, remoteUrl); + const workspace = await buildWorkspaceFromRepo(directory, name, remoteUrl); await storage.upsertWorkspace(workspace); await showToast({ style: Toast.Style.Success, @@ -219,7 +190,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true return ( ) : null} diff --git a/QuickShell.Raycast/src/components/set-target-branch-form.tsx b/QuickShell.Raycast/src/components/set-target-branch-form.tsx index 457480ce..c1456332 100644 --- a/QuickShell.Raycast/src/components/set-target-branch-form.tsx +++ b/QuickShell.Raycast/src/components/set-target-branch-form.tsx @@ -1,5 +1,6 @@ import { Action, ActionPanel, Form, Toast, showToast, useNavigation } from "@raycast/api"; -import { useForm } from "@raycast/utils"; +import { useForm, usePromise } from "@raycast/utils"; +import { useEffect } from "react"; import { evaluateAfterSettingTarget, isSafeGitBranchName, @@ -20,7 +21,14 @@ export default function SetTargetBranchForm({ directory, workspaceName, blockDir const { pop } = useNavigation(); const storage = getQuickShellStorage(); - const { handleSubmit, itemProps, setValue } = useForm<{ branch: string }>({ + const { data: branchChoices, isLoading } = usePromise(async () => { + const branches = await listLocalBranches(directory); + const worktreeKey = await resolveWorktreeKey(directory); + const target = worktreeKey ? await storage.getBranchTarget(worktreeKey) : null; + return { branches, target }; + }, [directory]); + + const { handleSubmit, itemProps, setValue, values } = useForm<{ branch: string }>({ async onSubmit(values) { const branch = values.branch.trim(); if (!branch) { @@ -80,32 +88,40 @@ export default function SetTargetBranchForm({ directory, workspaceName, blockDir }, }); + useEffect(() => { + const branches = branchChoices?.branches ?? []; + if (values.branch || branches.length === 0) { + return; + } + setValue( + "branch", + branchChoices?.target && branches.includes(branchChoices.target) ? branchChoices.target : branches[0], + ); + }, [branchChoices, setValue, values.branch]); + return (
- - { - const branches = await listLocalBranches(directory); - if (branches.length === 0) { - await showToast({ style: Toast.Style.Failure, title: "No local branches found" }); - return; - } - setValue("branch", branches[0]); - await showToast({ - style: Toast.Style.Success, - title: "Branches loaded", - message: branches.slice(0, 8).join(", "), - }); - }} + } > - - + + {branchChoices && branchChoices.branches.length === 0 ? ( + + ) : ( + + {(branchChoices?.branches ?? []).map((branch) => ( + + ))} + + )} ); } diff --git a/QuickShell.Raycast/src/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index 6862c230..475bbff1 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -43,6 +43,7 @@ import { applySuggestionPillToLaunchRows, buildWorkspaceFromFormState, createEmptyCompanionFormRow, + encodePillKey, launchRowsFromSuggestions, terminalForAddedLaunch, type CompanionFormRow, @@ -694,6 +695,44 @@ export default function WorkspaceForm({ placeholder={index === 0 ? "npm run dev" : "dotnet watch"} /> ))} + {unusedSuggestionPills.length > 0 ? ( + { + if (key === "choose-suggestion") { + return; + } + const pill = unusedSuggestionPills.find((candidate) => encodePillKey(candidate) === key); + if (pill) { + applySuggestionPill(pill); + } + }} + > + + {unusedSuggestionPills.map((pill) => ( + + ))} + + ) : null} + {suggestionSource ? ( + + ) : null} {launches.length > 1 ? launches.map((launch, index) => ( ) : null} - {suggestionSource ? ( - - ) : null} {!isMacPlatform() ? : null} diff --git a/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts b/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts new file mode 100644 index 00000000..4dcc2e02 --- /dev/null +++ b/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts @@ -0,0 +1,64 @@ +import { deriveAbbreviationFromName, deriveNameFromDirectory } from "./directory-helpers"; +import { createStableId } from "./ids"; +import type { WorkspaceSetupTask } from "./project-setup-suggestion"; +import type { CompanionAppEntry, Workspace } from "./schema"; +import { launchRowsFromSuggestions } from "./workspace-form-state"; +import { normalizeWorkspace } from "./validation"; + +type DiscoveredWorkspaceSeed = { + directory: string; + name: string; + remoteUrl?: string | null; + devServerUrl?: string | null; + tasks: WorkspaceSetupTask[]; + companionSeed?: Pick | null; +}; + +/** + * Full seed for a repository selected from Discover Git Repos. + * Manual Add Workspace intentionally uses createWorkspaceFromDirectory instead. + */ +export function createWorkspaceFromDiscoveredGitRepo(seed: DiscoveredWorkspaceSeed): Workspace { + const directory = seed.directory.trim(); + const name = seed.name.trim() || deriveNameFromDirectory(directory); + const launches = launchRowsFromSuggestions(seed.tasks).map((row, index) => ({ + id: row.id, + label: row.label, + terminal: row.terminal, + wtProfile: row.wtProfile ?? null, + command: row.command || null, + runAsAdmin: row.runAsAdmin, + isEnabled: row.isEnabled, + order: index, + taskType: row.taskType || "none", + })); + + return normalizeWorkspace({ + id: createStableId(), + name, + abbreviation: deriveAbbreviationFromName(name), + directory, + isPinned: false, + pinOrder: null, + lastUsedUtc: null, + terminal: "default", + wtProfile: null, + command: null, + runAsAdmin: false, + repoUrl: seed.remoteUrl ?? null, + devServerUrl: seed.devServerUrl ?? null, + // normalizeWorkspace supplies a usable blank launch when discovery finds no tasks. + launches, + companionApps: seed.companionSeed + ? [ + { + id: createStableId(), + path: seed.companionSeed.path, + arguments: seed.companionSeed.arguments ?? null, + openOnLaunch: true, + order: 0, + }, + ] + : [], + }); +} diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index 6856e779..ad05cf13 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -40,9 +40,18 @@ const SKIP_DIRS = new Set( const MAX_REPOS = 50; const MAX_SCANNED = 2000; +const MAX_TARGETED_SCANNED = 20000; const MAX_DEPTH = 5; const DEFAULT_CONCURRENCY = 4; +type DiscoveryOptions = { + concurrency?: number; + maxRepos?: number; + maxScanned?: number; + query?: string; + rootDirectories?: string[]; +}; + export function discoverGitRepos(extraRoots: string[] = []): GitRepoCandidate[] { return discoverGitReposSync(extraRoots); } @@ -57,13 +66,13 @@ const cachedDiscoverGitRepos = withCache(async (extraRoots: string[] = []) => di export async function discoverGitReposAsync( extraRoots: string[] = [], - options?: { concurrency?: number }, + options?: DiscoveryOptions, ): Promise { if (!isWindowsPlatform() && !isMacPlatform()) { return []; } - const roots = buildSearchRoots(extraRoots); + const roots = options?.rootDirectories ?? buildSearchRoots(extraRoots); if (roots.length === 0) { return []; } @@ -76,9 +85,12 @@ export async function discoverGitReposAsync( depth: 0, })); const concurrency = Math.max(1, options?.concurrency ?? DEFAULT_CONCURRENCY); + const maxRepos = options?.maxRepos ?? MAX_REPOS; + const maxScanned = options?.maxScanned ?? MAX_SCANNED; + const query = normalizeDiscoveryQuery(options?.query ?? ""); async function worker(): Promise { - while (results.length < MAX_REPOS && scanned < MAX_SCANNED) { + while (results.length < maxRepos && scanned < maxScanned) { const work = queue.shift(); if (!work) { return; @@ -98,13 +110,13 @@ export async function discoverGitReposAsync( } if (existsSync(path.join(work.directory, ".git"))) { - addRepo(work.directory, results, seen); + addRepo(work.directory, results, seen, query, maxRepos); continue; } // Match Core: only non-repo directories consume the scan budget. scanned += 1; - if (scanned >= MAX_SCANNED || results.length >= MAX_REPOS) { + if (scanned >= maxScanned || results.length >= maxRepos) { return; } @@ -127,7 +139,7 @@ export async function discoverGitReposAsync( } } - while (queue.length > 0 && results.length < MAX_REPOS && scanned < MAX_SCANNED) { + while (queue.length > 0 && results.length < maxRepos && scanned < maxScanned) { const waveSize = Math.min(concurrency, queue.length); await Promise.all(Array.from({ length: waveSize }, () => worker())); } @@ -135,6 +147,34 @@ export async function discoverGitReposAsync( return sortCandidates(results); } +/** + * Search beyond the normal 50-result discovery cap. An exact path bypasses the + * tree scan entirely, while a name query filters during a larger targeted scan. + */ +export async function discoverGitReposForQueryAsync( + searchText: string, + extraRoots: string[] = [], + options?: { rootDirectories?: string[]; concurrency?: number; maxScanned?: number }, +): Promise { + const query = normalizeDiscoveryQuery(searchText); + if (!query) { + return []; + } + + const pathMatch = repoCandidateFromPathQuery(searchText); + if (pathMatch) { + return [pathMatch]; + } + + return discoverGitReposAsync(extraRoots, { + concurrency: options?.concurrency, + maxRepos: Number.POSITIVE_INFINITY, + maxScanned: options?.maxScanned ?? MAX_TARGETED_SCANNED, + query, + rootDirectories: options?.rootDirectories, + }); +} + function discoverGitReposSync(extraRoots: string[] = []): GitRepoCandidate[] { if (!isWindowsPlatform() && !isMacPlatform()) { return []; @@ -196,8 +236,14 @@ function discoverGitReposSync(extraRoots: string[] = []): GitRepoCandidate[] { return sortCandidates(results); } -function addRepo(directory: string, results: GitRepoCandidate[], seen: Set): void { - if (results.length >= MAX_REPOS) { +function addRepo( + directory: string, + results: GitRepoCandidate[], + seen: Set, + query = "", + maxRepos = MAX_REPOS, +): void { + if (results.length >= maxRepos) { return; } const normalized = path.normalize(directory); @@ -206,13 +252,51 @@ function addRepo(directory: string, results: GitRepoCandidate[], seen: Set left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); } diff --git a/QuickShell.Raycast/src/open-workspace.tsx b/QuickShell.Raycast/src/open-workspace.tsx index 0b5d2d81..0097efaa 100644 --- a/QuickShell.Raycast/src/open-workspace.tsx +++ b/QuickShell.Raycast/src/open-workspace.tsx @@ -1147,7 +1147,7 @@ export default function OpenWorkspaceCommand({ {!isWorkspaceTrustEnabled() || security.isTrusted ? ( Date: Wed, 29 Jul 2026 00:26:44 -0700 Subject: [PATCH 02/15] Address Raycast discovery review findings --- .../discovered-workspace-seed.test.ts | 20 ++++++++ .../src/__tests__/git-repo-discovery.test.ts | 28 +++++++++++ .../components/discover-git-repos-view.tsx | 9 ++-- .../src/components/set-target-branch-form.tsx | 29 ++++++++---- .../src/lib/discovered-workspace-seed.ts | 3 +- .../src/lib/git-repo-discovery.ts | 47 +++++++++++++++++-- 6 files changed, 119 insertions(+), 17 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts b/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts index 01cb1beb..b8e6ba3a 100644 --- a/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts +++ b/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { createWorkspaceFromDiscoveredGitRepo } from "../lib/discovered-workspace-seed"; describe("createWorkspaceFromDiscoveredGitRepo", () => { @@ -37,4 +40,21 @@ describe("createWorkspaceFromDiscoveredGitRepo", () => { expect(workspace.launches).toHaveLength(1); expect(workspace.launches[0].command).toBeNull(); }); + + it("reads the origin remote when discovery did not populate one", () => { + const directory = mkdtempSync(path.join(os.tmpdir(), "quickshell-discovered-seed-")); + try { + mkdirSync(path.join(directory, ".git")); + writeFileSync( + path.join(directory, ".git", "config"), + '[remote "origin"]\n\turl = git@github.com:trackdub/trackdub.git\n', + ); + + const workspace = createWorkspaceFromDiscoveredGitRepo({ directory, name: "Trackdub", tasks: [] }); + + expect(workspace.repoUrl).toBe("https://github.com/trackdub/trackdub"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); }); diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index 44b5de79..6e9eca95 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -138,4 +138,32 @@ describe("discoverGitReposForQueryAsync", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("stops a targeted scan when it is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + + const repos = await discoverGitReposForQueryAsync("repo", [], { signal: controller.signal }); + + expect(repos).toEqual([]); + }); + + it("bounds total visited repositories without restoring the result cap", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-budget-")); + try { + for (let index = 0; index < 20; index += 1) { + mkdirSync(path.join(root, `repo-${index.toString().padStart(2, "0")}`, ".git"), { recursive: true }); + } + + const repos = await discoverGitReposForQueryAsync("repo-", [], { + rootDirectories: [root], + concurrency: 1, + maxVisited: 11, + }); + + expect(repos).toHaveLength(10); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index df48edec..bc63994e 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -94,13 +94,14 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true useEffect(() => { const query = searchText.trim(); + setTargetedLoadingQuery(null); if (!query || !data) { setTargetedSearch(null); - setTargetedLoadingQuery(null); return; } let cancelled = false; + const controller = new AbortController(); const timer = setTimeout(() => { setTargetedLoadingQuery(query); void (async () => { @@ -108,15 +109,16 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true const existing = await storage.getWorkspaces(); const existingDirs = new Set(existing.map((workspace) => workspace.directory.toLowerCase())); const extraRoots = searchRootsFromWorkspaces(existing.map((workspace) => workspace.directory)); - const repos = (await discoverGitReposForQueryAsync(query, extraRoots)).filter( + const repos = (await discoverGitReposForQueryAsync(query, extraRoots, { signal: controller.signal })).filter( (repo) => !existingDirs.has(repo.directory.toLowerCase()), ); if (!cancelled) { setTargetedSearch({ query, repos }); } - } catch { + } catch (searchError) { if (!cancelled) { setTargetedSearch({ query, repos: [] }); + await showStorageFailure("Search git repositories", searchError); } } finally { if (!cancelled) { @@ -128,6 +130,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true return () => { cancelled = true; + controller.abort(); clearTimeout(timer); }; }, [data, searchText]); diff --git a/QuickShell.Raycast/src/components/set-target-branch-form.tsx b/QuickShell.Raycast/src/components/set-target-branch-form.tsx index c1456332..192de476 100644 --- a/QuickShell.Raycast/src/components/set-target-branch-form.tsx +++ b/QuickShell.Raycast/src/components/set-target-branch-form.tsx @@ -21,7 +21,12 @@ export default function SetTargetBranchForm({ directory, workspaceName, blockDir const { pop } = useNavigation(); const storage = getQuickShellStorage(); - const { data: branchChoices, isLoading } = usePromise(async () => { + const { + data: branchChoices, + isLoading, + error, + revalidate, + } = usePromise(async () => { const branches = await listLocalBranches(directory); const worktreeKey = await resolveWorktreeKey(directory); const target = worktreeKey ? await storage.getBranchTarget(worktreeKey) : null; @@ -104,17 +109,25 @@ export default function SetTargetBranchForm({ directory, workspaceName, blockDir isLoading={isLoading} actions={ - + + {error ? : null} } > - {branchChoices && branchChoices.branches.length === 0 ? ( - + {error ? ( + <> + + + + ) : branchChoices && branchChoices.branches.length === 0 ? ( + <> + + + ) : ( {(branchChoices?.branches ?? []).map((branch) => ( diff --git a/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts b/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts index 4dcc2e02..e8480dc0 100644 --- a/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts +++ b/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts @@ -1,4 +1,5 @@ import { deriveAbbreviationFromName, deriveNameFromDirectory } from "./directory-helpers"; +import { tryGetGitRemoteUrl } from "./git-remote-url"; import { createStableId } from "./ids"; import type { WorkspaceSetupTask } from "./project-setup-suggestion"; import type { CompanionAppEntry, Workspace } from "./schema"; @@ -45,7 +46,7 @@ export function createWorkspaceFromDiscoveredGitRepo(seed: DiscoveredWorkspaceSe wtProfile: null, command: null, runAsAdmin: false, - repoUrl: seed.remoteUrl ?? null, + repoUrl: seed.remoteUrl ?? tryGetGitRemoteUrl(directory), devServerUrl: seed.devServerUrl ?? null, // normalizeWorkspace supplies a usable blank launch when discovery finds no tasks. launches, diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index ad05cf13..e738b235 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -41,6 +41,7 @@ const SKIP_DIRS = new Set( const MAX_REPOS = 50; const MAX_SCANNED = 2000; const MAX_TARGETED_SCANNED = 20000; +const MAX_TARGETED_VISITED = 25000; const MAX_DEPTH = 5; const DEFAULT_CONCURRENCY = 4; @@ -48,8 +49,10 @@ type DiscoveryOptions = { concurrency?: number; maxRepos?: number; maxScanned?: number; + maxVisited?: number; query?: string; rootDirectories?: string[]; + signal?: AbortSignal; }; export function discoverGitRepos(extraRoots: string[] = []): GitRepoCandidate[] { @@ -80,6 +83,7 @@ export async function discoverGitReposAsync( const results: GitRepoCandidate[] = []; const seen = new Set(); let scanned = 0; + let visited = 0; const queue: Array<{ directory: string; depth: number }> = roots.map((directory) => ({ directory, depth: 0, @@ -87,14 +91,17 @@ export async function discoverGitReposAsync( const concurrency = Math.max(1, options?.concurrency ?? DEFAULT_CONCURRENCY); const maxRepos = options?.maxRepos ?? MAX_REPOS; const maxScanned = options?.maxScanned ?? MAX_SCANNED; + const maxVisited = options?.maxVisited ?? Number.POSITIVE_INFINITY; const query = normalizeDiscoveryQuery(options?.query ?? ""); + const signal = options?.signal; async function worker(): Promise { - while (results.length < maxRepos && scanned < maxScanned) { + while (!signal?.aborted && results.length < maxRepos && scanned < maxScanned && visited < maxVisited) { const work = queue.shift(); if (!work) { return; } + visited += 1; if (work.depth > MAX_DEPTH) { continue; } @@ -105,6 +112,9 @@ export async function discoverGitReposAsync( } catch { continue; } + if (signal?.aborted) { + return; + } if (!isDirectory) { continue; } @@ -126,8 +136,14 @@ export async function discoverGitReposAsync( } catch { continue; } + if (signal?.aborted) { + return; + } for (const entry of entries) { + if (signal?.aborted) { + return; + } if (!entry.isDirectory() || SKIP_DIRS.has(entry.name.toLowerCase())) { continue; } @@ -139,7 +155,13 @@ export async function discoverGitReposAsync( } } - while (queue.length > 0 && results.length < maxRepos && scanned < maxScanned) { + while ( + !signal?.aborted && + queue.length > 0 && + results.length < maxRepos && + scanned < maxScanned && + visited < maxVisited + ) { const waveSize = Math.min(concurrency, queue.length); await Promise.all(Array.from({ length: waveSize }, () => worker())); } @@ -154,8 +176,17 @@ export async function discoverGitReposAsync( export async function discoverGitReposForQueryAsync( searchText: string, extraRoots: string[] = [], - options?: { rootDirectories?: string[]; concurrency?: number; maxScanned?: number }, + options?: { + rootDirectories?: string[]; + concurrency?: number; + maxScanned?: number; + maxVisited?: number; + signal?: AbortSignal; + }, ): Promise { + if (options?.signal?.aborted) { + return []; + } const query = normalizeDiscoveryQuery(searchText); if (!query) { return []; @@ -170,8 +201,10 @@ export async function discoverGitReposForQueryAsync( concurrency: options?.concurrency, maxRepos: Number.POSITIVE_INFINITY, maxScanned: options?.maxScanned ?? MAX_TARGETED_SCANNED, + maxVisited: options?.maxVisited ?? MAX_TARGETED_VISITED, query, rootDirectories: options?.rootDirectories, + signal: options?.signal, }); } @@ -280,8 +313,12 @@ function repoCandidateFromPathQuery(value: string): GitRepoCandidate | null { if (!statSync(candidate).isDirectory()) { candidate = path.dirname(candidate); } - } catch { - return null; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return null; + } + throw error; } while (true) { From 37e55f39d435fe5e12afb676cf22b5af579a605b Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 00:36:48 -0700 Subject: [PATCH 03/15] Keep targeted discovery independent and depth bounded --- .../src/components/discover-git-repos-view.tsx | 14 ++++++-------- QuickShell.Raycast/src/lib/git-repo-discovery.ts | 6 ++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index bc63994e..1469a2e3 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -95,7 +95,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true useEffect(() => { const query = searchText.trim(); setTargetedLoadingQuery(null); - if (!query || !data) { + if (!query) { setTargetedSearch(null); return; } @@ -133,17 +133,15 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true controller.abort(); clearTimeout(timer); }; - }, [data, searchText]); + }, [searchText]); const filtered = useMemo(() => { - if (!data) { - return []; - } + const cached = data ?? []; const query = searchText.trim().toLowerCase(); if (!query) { - return data; + return cached; } - const cachedMatches = data.filter( + const cachedMatches = cached.filter( (repo) => repo.name.toLowerCase().includes(query) || repo.directory.toLowerCase().includes(query) || @@ -199,7 +197,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true searchBarPlaceholder="Search discovered git repositories..." throttle > - {error ? ( + {error && filtered.length === 0 ? ( ) : null} diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index e738b235..99f24031 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -129,6 +129,9 @@ export async function discoverGitReposAsync( if (scanned >= maxScanned || results.length >= maxRepos) { return; } + if (work.depth >= MAX_DEPTH) { + continue; + } let entries: Array<{ name: string; isDirectory: () => boolean }> = []; try { @@ -247,6 +250,9 @@ function discoverGitReposSync(extraRoots: string[] = []): GitRepoCandidate[] { if (scanned >= MAX_SCANNED) { break; } + if (work.depth >= MAX_DEPTH) { + continue; + } let entries: Array<{ name: string; isDirectory: () => boolean }> = []; try { From 35e883310ffa12be457bb317e7781f237a41fa22 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 00:53:34 -0700 Subject: [PATCH 04/15] Address final Raycast review comments --- .../src/components/discover-git-repos-view.tsx | 13 +++++++++---- .../src/components/set-target-branch-form.tsx | 4 ++-- QuickShell.Raycast/src/lib/git-repo-discovery.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index 1469a2e3..70b58256 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -75,6 +75,13 @@ type DiscoverGitReposViewProps = { popOnAdd?: boolean; }; +function discoveryContextForWorkspaces(workspaces: Workspace[]) { + return { + existingDirs: new Set(workspaces.map((workspace) => workspace.directory.toLowerCase())), + extraRoots: searchRootsFromWorkspaces(workspaces.map((workspace) => workspace.directory)), + }; +} + export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true }: DiscoverGitReposViewProps) { const [searchText, setSearchText] = useState(""); const [targetedSearch, setTargetedSearch] = useState<{ query: string; repos: GitRepoCandidate[] } | null>(null); @@ -84,9 +91,8 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true const { data, isLoading, error, revalidate } = usePromise(async () => { const existing = await storage.getWorkspaces(); - const extraRoots = searchRootsFromWorkspaces(existing.map((workspace) => workspace.directory)); + const { existingDirs, extraRoots } = discoveryContextForWorkspaces(existing); const repos = await discoverGitReposCached(extraRoots); - const existingDirs = new Set(existing.map((workspace) => workspace.directory.toLowerCase())); return repos.filter((repo) => !existingDirs.has(repo.directory.toLowerCase())); }, []); @@ -107,8 +113,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true void (async () => { try { const existing = await storage.getWorkspaces(); - const existingDirs = new Set(existing.map((workspace) => workspace.directory.toLowerCase())); - const extraRoots = searchRootsFromWorkspaces(existing.map((workspace) => workspace.directory)); + const { existingDirs, extraRoots } = discoveryContextForWorkspaces(existing); const repos = (await discoverGitReposForQueryAsync(query, extraRoots, { signal: controller.signal })).filter( (repo) => !existingDirs.has(repo.directory.toLowerCase()), ); diff --git a/QuickShell.Raycast/src/components/set-target-branch-form.tsx b/QuickShell.Raycast/src/components/set-target-branch-form.tsx index 192de476..e39cd41f 100644 --- a/QuickShell.Raycast/src/components/set-target-branch-form.tsx +++ b/QuickShell.Raycast/src/components/set-target-branch-form.tsx @@ -123,14 +123,14 @@ export default function SetTargetBranchForm({ directory, workspaceName, blockDir /> - ) : branchChoices && branchChoices.branches.length === 0 ? ( + ) : !branchChoices ? null : branchChoices.branches.length === 0 ? ( <> ) : ( - {(branchChoices?.branches ?? []).map((branch) => ( + {branchChoices.branches.map((branch) => ( ))} diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index 99f24031..19aa5d8c 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -321,7 +321,7 @@ function repoCandidateFromPathQuery(value: string): GitRepoCandidate | null { } } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") { + if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "EPERM" || code === "ENAMETOOLONG") { return null; } throw error; From 278b3258559c76e647b883e2a69b978035001c71 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 02:02:14 -0700 Subject: [PATCH 05/15] Address remaining Raycast review findings --- .../src/__tests__/git-repo-discovery.test.ts | 16 ++++++++++++++++ .../src/components/discover-git-repos-view.tsx | 6 ++++-- .../src/components/set-target-branch-form.tsx | 2 +- QuickShell.Raycast/src/lib/git-repo-discovery.ts | 4 ++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index 6e9eca95..877dd262 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -108,6 +108,22 @@ describe("buildSearchRoots", () => { }); describe("discoverGitReposForQueryAsync", () => { + it("does not probe direct paths on unsupported platforms", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-platform-")); + const repo = path.join(root, "repo"); + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + try { + mkdirSync(path.join(repo, ".git"), { recursive: true }); + + const repos = await discoverGitReposForQueryAsync(repo); + + expect(repos).toEqual([]); + } finally { + platform.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); + it("bypasses the normal result cap for typed name searches", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-search-")); try { diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index 70b58256..7a33f6fb 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -86,6 +86,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true const [searchText, setSearchText] = useState(""); const [targetedSearch, setTargetedSearch] = useState<{ query: string; repos: GitRepoCandidate[] } | null>(null); const [targetedLoadingQuery, setTargetedLoadingQuery] = useState(null); + const [addedDirectoryKeys, setAddedDirectoryKeys] = useState>(() => new Set()); const { pop } = useNavigation(); const storage = getQuickShellStorage(); @@ -158,16 +159,17 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true ...cachedMatches, ...targetedMatches.filter((repo) => { const key = repo.directory.toLowerCase(); - if (seen.has(key)) { + if (addedDirectoryKeys.has(key) || seen.has(key)) { return false; } seen.add(key); return true; }), ]; - }, [data, searchText, targetedSearch]); + }, [addedDirectoryKeys, data, searchText, targetedSearch]); async function finishAdd(workspace: Workspace) { + setAddedDirectoryKeys((current) => new Set(current).add(workspace.directory.toLowerCase())); await revalidate(); await onWorkspaceAdded?.(workspace); if (popOnAdd) { diff --git a/QuickShell.Raycast/src/components/set-target-branch-form.tsx b/QuickShell.Raycast/src/components/set-target-branch-form.tsx index e39cd41f..70854f7b 100644 --- a/QuickShell.Raycast/src/components/set-target-branch-form.tsx +++ b/QuickShell.Raycast/src/components/set-target-branch-form.tsx @@ -109,7 +109,7 @@ export default function SetTargetBranchForm({ directory, workspaceName, blockDir isLoading={isLoading} actions={ - + {error ? : null} } diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index 19aa5d8c..8d602670 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -187,6 +187,10 @@ export async function discoverGitReposForQueryAsync( signal?: AbortSignal; }, ): Promise { + if (!isWindowsPlatform() && !isMacPlatform()) { + return []; + } + if (options?.signal?.aborted) { return []; } From e181ae42dcceeebe4d3ce4c43131e6e65cd6d66c Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 02:12:32 -0700 Subject: [PATCH 06/15] Hide newly added repos from cached discovery --- .../src/components/discover-git-repos-view.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index 7a33f6fb..e4498969 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -143,11 +143,12 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true const filtered = useMemo(() => { const cached = data ?? []; + const cachedVisible = cached.filter((repo) => !addedDirectoryKeys.has(repo.directory.toLowerCase())); const query = searchText.trim().toLowerCase(); if (!query) { - return cached; + return cachedVisible; } - const cachedMatches = cached.filter( + const cachedMatches = cachedVisible.filter( (repo) => repo.name.toLowerCase().includes(query) || repo.directory.toLowerCase().includes(query) || From 7c4950e9deb084bc2eebc2cd0e921a4544b52d2b Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 02:24:02 -0700 Subject: [PATCH 07/15] Prune temporary discovery suppression --- .../src/components/discover-git-repos-view.tsx | 11 ++++++++++- 1 file changed, 10 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 e4498969..db523ea5 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -170,8 +170,17 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true }, [addedDirectoryKeys, data, searchText, targetedSearch]); async function finishAdd(workspace: Workspace) { - setAddedDirectoryKeys((current) => new Set(current).add(workspace.directory.toLowerCase())); + const addedKey = workspace.directory.toLowerCase(); + setAddedDirectoryKeys((current) => new Set(current).add(addedKey)); + setTargetedSearch((current) => + current ? { ...current, repos: current.repos.filter((repo) => repo.directory.toLowerCase() !== addedKey) } : null, + ); await revalidate(); + setAddedDirectoryKeys((current) => { + const next = new Set(current); + next.delete(addedKey); + return next; + }); await onWorkspaceAdded?.(workspace); if (popOnAdd) { pop(); From ecbe5e6d60edbd3413a1d29592cdbec6097e0dd5 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 02:34:30 -0700 Subject: [PATCH 08/15] Complete repo add when refresh fails --- .../src/components/discover-git-repos-view.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index db523ea5..a92e47e0 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -175,12 +175,17 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true setTargetedSearch((current) => current ? { ...current, repos: current.repos.filter((repo) => repo.directory.toLowerCase() !== addedKey) } : null, ); - await revalidate(); - setAddedDirectoryKeys((current) => { - const next = new Set(current); - next.delete(addedKey); - return next; - }); + try { + await revalidate(); + } catch (refreshError) { + await showStorageFailure("Refresh git repositories", refreshError); + } finally { + setAddedDirectoryKeys((current) => { + const next = new Set(current); + next.delete(addedKey); + return next; + }); + } await onWorkspaceAdded?.(workspace); if (popOnAdd) { pop(); From 5fcbb011aee9d53f21c128840bb686e15ed87042 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 02:44:45 -0700 Subject: [PATCH 09/15] Reconcile discovery suppression after refresh --- .../src/components/discover-git-repos-view.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index a92e47e0..5c7c8d19 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -99,6 +99,18 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true useLoadErrorToast(error, "Failed to scan git repositories"); + useEffect(() => { + if (!data) { + return; + } + + const cachedKeys = new Set(data.map((repo) => repo.directory.toLowerCase())); + setAddedDirectoryKeys((current) => { + const next = new Set([...current].filter((key) => cachedKeys.has(key))); + return next.size === current.size ? current : next; + }); + }, [data]); + useEffect(() => { const query = searchText.trim(); setTargetedLoadingQuery(null); @@ -179,12 +191,6 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true await revalidate(); } catch (refreshError) { await showStorageFailure("Refresh git repositories", refreshError); - } finally { - setAddedDirectoryKeys((current) => { - const next = new Set(current); - next.delete(addedKey); - return next; - }); } await onWorkspaceAdded?.(workspace); if (popOnAdd) { From 21aad23be9cec49168326b67c7951c39deb459db Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 02:56:40 -0700 Subject: [PATCH 10/15] Reserve concurrent git scan capacity --- .../src/__tests__/git-repo-discovery.test.ts | 33 +++++++++++++++++++ .../src/lib/git-repo-discovery.ts | 8 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index 877dd262..2ab5d5ef 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { buildSearchRoots, + discoverGitReposAsync, discoverGitReposForQueryAsync, listDefaultRootCandidates, searchRootsFromWorkspaces, @@ -183,3 +185,34 @@ describe("discoverGitReposForQueryAsync", () => { } }); }); + +describe("discoverGitReposAsync", () => { + it("reserves scan capacity before concurrent stat calls", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-concurrency-")); + const roots = Array.from({ length: 4 }, (_, index) => path.join(root, `root-${index}`)); + roots.forEach((directory) => mkdirSync(directory)); + + const originalStat = fs.stat.bind(fs); + let activeStats = 0; + let maxActiveStats = 0; + const stat = vi.spyOn(fs, "stat").mockImplementation(async (candidate) => { + activeStats += 1; + maxActiveStats = Math.max(maxActiveStats, activeStats); + await new Promise((resolve) => setTimeout(resolve, 10)); + try { + return await originalStat(candidate); + } finally { + activeStats -= 1; + } + }); + + try { + await discoverGitReposAsync([], { rootDirectories: roots, concurrency: 4, maxScanned: 1 }); + + expect(maxActiveStats).toBe(1); + } finally { + stat.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index 8d602670..2adf2ccc 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -106,26 +106,32 @@ export async function discoverGitReposAsync( continue; } + // Reserve capacity before yielding so concurrent workers cannot all pass + // the admission check and later exceed maxScanned. + scanned += 1; let isDirectory = false; try { isDirectory = (await fs.stat(work.directory)).isDirectory(); } catch { + scanned -= 1; continue; } if (signal?.aborted) { + scanned -= 1; return; } if (!isDirectory) { + scanned -= 1; continue; } if (existsSync(path.join(work.directory, ".git"))) { + scanned -= 1; addRepo(work.directory, results, seen, query, maxRepos); continue; } // Match Core: only non-repo directories consume the scan budget. - scanned += 1; if (scanned >= maxScanned || results.length >= maxRepos) { return; } From 2ea919c77a1eaf74434e6a89eed0dab44ce90c73 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 03:07:56 -0700 Subject: [PATCH 11/15] Disable pending git quick add --- .../src/components/discover-git-repos-view.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index 5c7c8d19..adb4ff1b 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -87,6 +87,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true const [targetedSearch, setTargetedSearch] = useState<{ query: string; repos: GitRepoCandidate[] } | null>(null); const [targetedLoadingQuery, setTargetedLoadingQuery] = useState(null); const [addedDirectoryKeys, setAddedDirectoryKeys] = useState>(() => new Set()); + const [pendingQuickAddKey, setPendingQuickAddKey] = useState(null); const { pop } = useNavigation(); const storage = getQuickShellStorage(); @@ -199,6 +200,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true } async function handleQuickAdd(directory: string, name: string, remoteUrl?: string | null) { + setPendingQuickAddKey(directory.toLowerCase()); try { const workspace = await buildWorkspaceFromRepo(directory, name, remoteUrl); await storage.upsertWorkspace(workspace); @@ -210,6 +212,8 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true await finishAdd(workspace); } catch (addError) { await showStorageFailure("Add workspace", addError); + } finally { + setPendingQuickAddKey(null); } } @@ -250,6 +254,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true title="Add Workspace" icon={Icon.Plus} onAction={() => handleQuickAdd(repo.directory, repo.name, repo.remoteUrl)} + disabled={pendingQuickAddKey === repo.directory.toLowerCase()} /> Date: Wed, 29 Jul 2026 04:43:25 -0700 Subject: [PATCH 12/15] Guard concurrent repository quick adds --- .../components/discover-git-repos-view.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index adb4ff1b..897e63ab 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -1,6 +1,6 @@ import { Action, ActionPanel, Icon, List, showToast, Toast, useNavigation } from "@raycast/api"; import { usePromise } from "@raycast/utils"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import WorkspaceForm from "./workspace-form"; import UnsupportedPlatformView from "./unsupported-platform-view"; import { detectCompanionSeed } from "../lib/companion-detection"; @@ -87,7 +87,8 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true const [targetedSearch, setTargetedSearch] = useState<{ query: string; repos: GitRepoCandidate[] } | null>(null); const [targetedLoadingQuery, setTargetedLoadingQuery] = useState(null); const [addedDirectoryKeys, setAddedDirectoryKeys] = useState>(() => new Set()); - const [pendingQuickAddKey, setPendingQuickAddKey] = useState(null); + const [pendingQuickAddKeys, setPendingQuickAddKeys] = useState>(() => new Set()); + const pendingQuickAddKeysRef = useRef(new Set()); const { pop } = useNavigation(); const storage = getQuickShellStorage(); @@ -200,7 +201,12 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true } async function handleQuickAdd(directory: string, name: string, remoteUrl?: string | null) { - setPendingQuickAddKey(directory.toLowerCase()); + const pendingKey = directory.toLowerCase(); + if (pendingQuickAddKeysRef.current.has(pendingKey)) { + return; + } + pendingQuickAddKeysRef.current.add(pendingKey); + setPendingQuickAddKeys((current) => new Set(current).add(pendingKey)); try { const workspace = await buildWorkspaceFromRepo(directory, name, remoteUrl); await storage.upsertWorkspace(workspace); @@ -213,7 +219,12 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true } catch (addError) { await showStorageFailure("Add workspace", addError); } finally { - setPendingQuickAddKey(null); + pendingQuickAddKeysRef.current.delete(pendingKey); + setPendingQuickAddKeys((current) => { + const next = new Set(current); + next.delete(pendingKey); + return next; + }); } } @@ -254,7 +265,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true title="Add Workspace" icon={Icon.Plus} onAction={() => handleQuickAdd(repo.directory, repo.name, repo.remoteUrl)} - disabled={pendingQuickAddKey === repo.directory.toLowerCase()} + disabled={pendingQuickAddKeys.has(repo.directory.toLowerCase())} /> Date: Wed, 29 Jul 2026 04:46:53 -0700 Subject: [PATCH 13/15] Surface unexpected git discovery stat failures --- .../src/__tests__/git-repo-discovery.test.ts | 19 +++++++++++++++++++ .../src/lib/git-repo-discovery.ts | 16 ++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index 2ab5d5ef..1ce5f1c2 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -187,6 +187,25 @@ describe("discoverGitReposForQueryAsync", () => { }); describe("discoverGitReposAsync", () => { + it("ignores expected stat lookup failures", async () => { + const stat = vi.spyOn(fs, "stat").mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); + try { + await expect(discoverGitReposAsync([], { rootDirectories: ["missing"] })).resolves.toEqual([]); + } finally { + stat.mockRestore(); + } + }); + + it("surfaces unexpected stat failures", async () => { + const failure = new Error("unexpected stat failure"); + const stat = vi.spyOn(fs, "stat").mockRejectedValue(failure); + try { + await expect(discoverGitReposAsync([], { rootDirectories: ["broken"] })).rejects.toBe(failure); + } finally { + stat.mockRestore(); + } + }); + it("reserves scan capacity before concurrent stat calls", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-concurrency-")); const roots = Array.from({ length: 4 }, (_, index) => path.join(root, `root-${index}`)); diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index 2adf2ccc..d0eac2f6 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -44,6 +44,7 @@ const MAX_TARGETED_SCANNED = 20000; const MAX_TARGETED_VISITED = 25000; const MAX_DEPTH = 5; const DEFAULT_CONCURRENCY = 4; +const EXPECTED_FS_LOOKUP_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ENAMETOOLONG"]); type DiscoveryOptions = { concurrency?: number; @@ -112,9 +113,12 @@ export async function discoverGitReposAsync( let isDirectory = false; try { isDirectory = (await fs.stat(work.directory)).isDirectory(); - } catch { + } catch (error) { scanned -= 1; - continue; + if (isExpectedFsLookupError(error)) { + continue; + } + throw error; } if (signal?.aborted) { scanned -= 1; @@ -330,8 +334,7 @@ function repoCandidateFromPathQuery(value: string): GitRepoCandidate | null { candidate = path.dirname(candidate); } } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "EPERM" || code === "ENAMETOOLONG") { + if (isExpectedFsLookupError(error)) { return null; } throw error; @@ -350,6 +353,11 @@ function repoCandidateFromPathQuery(value: string): GitRepoCandidate | null { } } +function isExpectedFsLookupError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException)?.code; + return typeof code === "string" && EXPECTED_FS_LOOKUP_ERROR_CODES.has(code); +} + function sortCandidates(results: GitRepoCandidate[]): GitRepoCandidate[] { return results.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); } From 6ec2edac1c50ce7817226e3a780e5b255027b659 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 05:07:44 -0700 Subject: [PATCH 14/15] test(raycast): cover mid-scan git discovery abort Ensure discoverGitReposForQueryAsync stops early when cancelled after work starts, not only when already aborted. Co-authored-by: Cursor --- .../src/__tests__/git-repo-discovery.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index 1ce5f1c2..348e6f91 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -166,6 +166,39 @@ describe("discoverGitReposForQueryAsync", () => { expect(repos).toEqual([]); }); + it("stops a targeted scan mid-flight when cancelled", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-abort-")); + const repoCount = 16; + const controller = new AbortController(); + const originalStat = fs.stat.bind(fs); + let statCalls = 0; + const stat = vi.spyOn(fs, "stat").mockImplementation(async (candidate) => { + statCalls += 1; + if (statCalls === 4) { + // Abort asynchronously after the scan has already started work. + queueMicrotask(() => controller.abort()); + } + return originalStat(candidate); + }); + + try { + for (let index = 0; index < repoCount; index += 1) { + mkdirSync(path.join(root, `repo-${index.toString().padStart(2, "0")}`, ".git"), { recursive: true }); + } + + const repos = await discoverGitReposForQueryAsync("repo-", [], { + rootDirectories: [root], + concurrency: 1, + signal: controller.signal, + }); + + expect(repos.length).toBeLessThan(repoCount); + } finally { + stat.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); + it("bounds total visited repositories without restoring the result cap", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-budget-")); try { From d8bec5b532dca832586bda27effdf65103738bf7 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 05:17:38 -0700 Subject: [PATCH 15/15] fix(raycast): cap discovery waves by remaining scan budget Prevent concurrent workers from oversubscribing maxScanned, and keep the name-search cap-bypass test under Vitest's default timeout on Windows CI. Co-authored-by: Cursor --- .../src/__tests__/git-repo-discovery.test.ts | 11 ++++++++--- QuickShell.Raycast/src/lib/git-repo-discovery.ts | 11 ++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts index 348e6f91..93bbc22d 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -128,14 +128,19 @@ describe("discoverGitReposForQueryAsync", () => { it("bypasses the normal result cap for typed name searches", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "quickshell-git-search-")); + const repoCount = 51; try { - for (let index = 0; index < 55; index += 1) { + for (let index = 0; index < repoCount; index += 1) { mkdirSync(path.join(root, `repo-${index.toString().padStart(2, "0")}`, ".git"), { recursive: true }); } - const repos = await discoverGitReposForQueryAsync("repo-", [], { rootDirectories: [root], concurrency: 1 }); + // Keep this under Vitest's default 5s budget on slow Windows CI hosts. + const repos = await discoverGitReposForQueryAsync("repo-", [], { + rootDirectories: [root], + concurrency: 4, + }); - expect(repos).toHaveLength(55); + expect(repos).toHaveLength(repoCount); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/QuickShell.Raycast/src/lib/git-repo-discovery.ts b/QuickShell.Raycast/src/lib/git-repo-discovery.ts index d0eac2f6..0a4d570f 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -175,7 +175,16 @@ export async function discoverGitReposAsync( scanned < maxScanned && visited < maxVisited ) { - const waveSize = Math.min(concurrency, queue.length); + // Cap the wave by remaining budget so workers cannot all clear the + // admission check and then oversubscribe concurrent fs.stat calls. + const remainingScanSlots = + maxScanned === Number.POSITIVE_INFINITY ? concurrency : Math.max(0, maxScanned - scanned); + const remainingVisitSlots = + maxVisited === Number.POSITIVE_INFINITY ? concurrency : Math.max(0, maxVisited - visited); + const waveSize = Math.min(concurrency, queue.length, remainingScanSlots, remainingVisitSlots); + if (waveSize <= 0) { + break; + } await Promise.all(Array.from({ length: waveSize }, () => worker())); }