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..b8e6ba3a --- /dev/null +++ b/QuickShell.Raycast/src/__tests__/discovered-workspace-seed.test.ts @@ -0,0 +1,60 @@ +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", () => { + 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(); + }); + + 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 be74ffc8..93bbc22d 100644 --- a/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts +++ b/QuickShell.Raycast/src/__tests__/git-repo-discovery.test.ts @@ -1,5 +1,19 @@ -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 fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + buildSearchRoots, + discoverGitReposAsync, + 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 +108,168 @@ describe("buildSearchRoots", () => { expect(roots.map((root) => root.toLowerCase())).not.toContain("c:\\users\\tonyt"); }); }); + +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-")); + const repoCount = 51; + try { + for (let index = 0; index < repoCount; index += 1) { + mkdirSync(path.join(root, `repo-${index.toString().padStart(2, "0")}`, ".git"), { recursive: true }); + } + + // 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(repoCount); + } 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 }); + } + }); + + 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("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 { + 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 }); + } + }); +}); + +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}`)); + 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/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index f1cf2eb9..897e63ab 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, useRef, 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), }); } @@ -159,39 +75,125 @@ 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); + const [targetedLoadingQuery, setTargetedLoadingQuery] = useState(null); + const [addedDirectoryKeys, setAddedDirectoryKeys] = useState>(() => new Set()); + const [pendingQuickAddKeys, setPendingQuickAddKeys] = useState>(() => new Set()); + const pendingQuickAddKeysRef = useRef(new Set()); const { pop } = useNavigation(); const storage = getQuickShellStorage(); 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())); }, []); useLoadErrorToast(error, "Failed to scan git repositories"); - const filtered = useMemo(() => { + useEffect(() => { if (!data) { - return []; + 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); + if (!query) { + setTargetedSearch(null); + return; } + + let cancelled = false; + const controller = new AbortController(); + const timer = setTimeout(() => { + setTargetedLoadingQuery(query); + void (async () => { + try { + const existing = await storage.getWorkspaces(); + const { existingDirs, extraRoots } = discoveryContextForWorkspaces(existing); + const repos = (await discoverGitReposForQueryAsync(query, extraRoots, { signal: controller.signal })).filter( + (repo) => !existingDirs.has(repo.directory.toLowerCase()), + ); + if (!cancelled) { + setTargetedSearch({ query, repos }); + } + } catch (searchError) { + if (!cancelled) { + setTargetedSearch({ query, repos: [] }); + await showStorageFailure("Search git repositories", searchError); + } + } finally { + if (!cancelled) { + setTargetedLoadingQuery(null); + } + } + })(); + }, 250); + + return () => { + cancelled = true; + controller.abort(); + clearTimeout(timer); + }; + }, [searchText]); + + const filtered = useMemo(() => { + const cached = data ?? []; + const cachedVisible = cached.filter((repo) => !addedDirectoryKeys.has(repo.directory.toLowerCase())); const query = searchText.trim().toLowerCase(); if (!query) { - return data; + return cachedVisible; } - return data.filter( + const cachedMatches = cachedVisible.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 (addedDirectoryKeys.has(key) || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }), + ]; + }, [addedDirectoryKeys, data, searchText, targetedSearch]); async function finishAdd(workspace: Workspace) { - await revalidate(); + 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, + ); + try { + await revalidate(); + } catch (refreshError) { + await showStorageFailure("Refresh git repositories", refreshError); + } await onWorkspaceAdded?.(workspace); if (popOnAdd) { pop(); @@ -199,8 +201,14 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true } async function handleQuickAdd(directory: string, name: string, remoteUrl?: string | null) { + const pendingKey = directory.toLowerCase(); + if (pendingQuickAddKeysRef.current.has(pendingKey)) { + return; + } + pendingQuickAddKeysRef.current.add(pendingKey); + setPendingQuickAddKeys((current) => new Set(current).add(pendingKey)); try { - const workspace = buildLightWorkspaceFromRepo(directory, name, remoteUrl); + const workspace = await buildWorkspaceFromRepo(directory, name, remoteUrl); await storage.upsertWorkspace(workspace); await showToast({ style: Toast.Style.Success, @@ -210,6 +218,13 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true await finishAdd(workspace); } catch (addError) { await showStorageFailure("Add workspace", addError); + } finally { + pendingQuickAddKeysRef.current.delete(pendingKey); + setPendingQuickAddKeys((current) => { + const next = new Set(current); + next.delete(pendingKey); + return next; + }); } } @@ -219,20 +234,22 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true return ( - {error ? ( + {error && filtered.length === 0 ? ( ) : null} {!error && filtered.length === 0 ? ( ) : null} @@ -248,6 +265,7 @@ export default function DiscoverGitReposView({ onWorkspaceAdded, popOnAdd = true title="Add Workspace" icon={Icon.Plus} onAction={() => handleQuickAdd(repo.directory, repo.name, repo.remoteUrl)} + disabled={pendingQuickAddKeys.has(repo.directory.toLowerCase())} /> ({ + 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; + return { branches, target }; + }, [directory]); + + const { handleSubmit, itemProps, setValue, values } = useForm<{ branch: string }>({ async onSubmit(values) { const branch = values.branch.trim(); if (!branch) { @@ -80,32 +93,48 @@ 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(", "), - }); - }} - /> + + {error ? : null} } > - - + + {error ? ( + <> + + + + ) : !branchChoices ? null : 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..e8480dc0 --- /dev/null +++ b/QuickShell.Raycast/src/lib/discovered-workspace-seed.ts @@ -0,0 +1,65 @@ +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"; +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 ?? tryGetGitRemoteUrl(directory), + 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..0a4d570f 100644 --- a/QuickShell.Raycast/src/lib/git-repo-discovery.ts +++ b/QuickShell.Raycast/src/lib/git-repo-discovery.ts @@ -40,8 +40,21 @@ 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; +const EXPECTED_FS_LOOKUP_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ENAMETOOLONG"]); + +type DiscoveryOptions = { + concurrency?: number; + maxRepos?: number; + maxScanned?: number; + maxVisited?: number; + query?: string; + rootDirectories?: string[]; + signal?: AbortSignal; +}; export function discoverGitRepos(extraRoots: string[] = []): GitRepoCandidate[] { return discoverGitReposSync(extraRoots); @@ -57,13 +70,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 []; } @@ -71,42 +84,64 @@ 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, })); 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 < MAX_REPOS && scanned < MAX_SCANNED) { + 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; } + // 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 { - continue; + } catch (error) { + scanned -= 1; + if (isExpectedFsLookupError(error)) { + continue; + } + throw error; + } + if (signal?.aborted) { + scanned -= 1; + return; } if (!isDirectory) { + scanned -= 1; continue; } if (existsSync(path.join(work.directory, ".git"))) { - addRepo(work.directory, results, seen); + scanned -= 1; + 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; } + if (work.depth >= MAX_DEPTH) { + continue; + } let entries: Array<{ name: string; isDirectory: () => boolean }> = []; try { @@ -114,8 +149,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; } @@ -127,14 +168,72 @@ export async function discoverGitReposAsync( } } - while (queue.length > 0 && results.length < MAX_REPOS && scanned < MAX_SCANNED) { - const waveSize = Math.min(concurrency, queue.length); + while ( + !signal?.aborted && + queue.length > 0 && + results.length < maxRepos && + scanned < maxScanned && + visited < maxVisited + ) { + // 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())); } 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; + maxVisited?: number; + signal?: AbortSignal; + }, +): Promise { + if (!isWindowsPlatform() && !isMacPlatform()) { + return []; + } + + if (options?.signal?.aborted) { + return []; + } + 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, + maxVisited: options?.maxVisited ?? MAX_TARGETED_VISITED, + query, + rootDirectories: options?.rootDirectories, + signal: options?.signal, + }); +} + function discoverGitReposSync(extraRoots: string[] = []): GitRepoCandidate[] { if (!isWindowsPlatform() && !isMacPlatform()) { return []; @@ -174,6 +273,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 { @@ -196,8 +298,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 +314,59 @@ 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 ? (