diff --git a/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx b/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx index 19a7b17042..c372123163 100644 --- a/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx +++ b/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx @@ -10,6 +10,7 @@ import { cn } from "@/common/lib/utils"; import assert from "@/common/utils/assert"; import type { GitStatus } from "@/common/types/workspace"; import type { MultiProjectGitSummary } from "@/browser/stores/GitStatusStore"; +import { formatRepoCount } from "./gitStatusFormatters"; interface MultiProjectDivergenceDialogProps { isOpen: boolean; @@ -18,10 +19,6 @@ interface MultiProjectDivergenceDialogProps { isRefreshing: boolean; } -function formatRepoCount(count: number): string { - return `${count} ${count === 1 ? "repo" : "repos"}`; -} - function formatLineDelta(additions: number, deletions: number): React.ReactNode { if (additions === 0 && deletions === 0) { return 0; diff --git a/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx b/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx index 937b5eab3a..59c4904280 100644 --- a/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx +++ b/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx @@ -10,6 +10,7 @@ import { stopKeyboardPropagation } from "@/browser/utils/events"; import { cn } from "@/common/lib/utils"; import assert from "@/common/utils/assert"; import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip/Tooltip"; +import { formatRepoCount } from "./gitStatusFormatters"; interface MultiProjectGitStatusIndicatorProps { workspaceId: string; @@ -24,10 +25,6 @@ interface ChipPresentation { className: string; } -function formatRepoCount(count: number): string { - return `${count} ${count === 1 ? "repo" : "repos"}`; -} - function formatCategoryCount(count: number, noun: string): string { return `${count} ${noun}`; } diff --git a/src/browser/components/GitStatusIndicator/gitStatusFormatters.ts b/src/browser/components/GitStatusIndicator/gitStatusFormatters.ts new file mode 100644 index 0000000000..8ec03ba6cd --- /dev/null +++ b/src/browser/components/GitStatusIndicator/gitStatusFormatters.ts @@ -0,0 +1,4 @@ +/** Pluralize a repo count: "1 repo" / "3 repos". */ +export function formatRepoCount(count: number): string { + return `${count} ${count === 1 ? "repo" : "repos"}`; +} diff --git a/src/browser/features/Analytics/DelegationChart.tsx b/src/browser/features/Analytics/DelegationChart.tsx index 9cb5f9c760..5a07a9cf50 100644 --- a/src/browser/features/Analytics/DelegationChart.tsx +++ b/src/browser/features/Analytics/DelegationChart.tsx @@ -8,7 +8,7 @@ import { XAxis, YAxis, } from "recharts"; -import assert from "@/common/utils/assert"; +import { capitalize } from "@/common/utils/capitalize"; import { Skeleton } from "@/browser/components/Skeleton/Skeleton"; import type { DelegationSummary } from "@/browser/hooks/useAnalytics"; import { @@ -51,14 +51,6 @@ const DELEGATION_TOKEN_CATEGORIES: Array<{ { key: "reasoningTokens", label: "Reasoning", color: TOKEN_CATEGORY_COLORS.reasoningTokens }, ]; -function capitalize(s: string): string { - assert(typeof s === "string", "capitalize expects a string"); - if (s.length === 0) { - return s; - } - return s.charAt(0).toUpperCase() + s.slice(1); -} - function formatCompressionRatio(compressionRatio: number): string { if (!Number.isFinite(compressionRatio) || compressionRatio <= 0) { return "N/A"; diff --git a/src/browser/hooks/useStableReference.ts b/src/browser/hooks/useStableReference.ts index 71fb6915a8..2bcbefbc9d 100644 --- a/src/browser/hooks/useStableReference.ts +++ b/src/browser/hooks/useStableReference.ts @@ -51,21 +51,6 @@ export function compareRecords( return true; } -/** - * Compare two Sets for equality (same size and values). - * - * @param prev Previous Set - * @param next Next Set - * @returns true if Sets are equal, false otherwise - */ -export function compareSets(a: Set, b: Set): boolean { - if (a.size !== b.size) return false; - for (const item of a) { - if (!b.has(item)) return false; - } - return true; -} - /** * Compare two Arrays for deep equality (same length and values). * Uses === for value comparison by default. diff --git a/src/common/utils/ai/modelDisplay.ts b/src/common/utils/ai/modelDisplay.ts index e89886378b..8ad9d3f9ab 100644 --- a/src/common/utils/ai/modelDisplay.ts +++ b/src/common/utils/ai/modelDisplay.ts @@ -2,6 +2,8 @@ * Formatting utilities for model display names */ +import { capitalize } from "../capitalize"; + /** * Format a model name for display with proper capitalization and spacing. * @@ -170,14 +172,6 @@ export function formatModelDisplayName(modelName: string): string { return modelName.split("-").map(capitalize).join(" "); } -/** - * Capitalize the first letter of a string - */ -function capitalize(str: string): string { - if (!str) return str; - return str.charAt(0).toUpperCase() + str.slice(1); -} - /** * Format version numbers: ["4", "5"] -> "4.5" */ diff --git a/src/common/utils/asyncEventIterator.ts b/src/common/utils/asyncEventIterator.ts index 068075f848..8a7c05e5c0 100644 --- a/src/common/utils/asyncEventIterator.ts +++ b/src/common/utils/asyncEventIterator.ts @@ -1,71 +1,3 @@ -/** - * Convert event emitter subscription to async iterator. - * - * Handles the common pattern of: - * 1. Subscribe to events - * 2. Yield events as async iterator - * 3. Unsubscribe on cleanup - * - * Usage: - * ```ts - * yield* asyncEventIterator( - * (handler) => emitter.on('event', handler), - * (handler) => emitter.off('event', handler) - * ); - * ``` - * - * Or with initialValue for immediate first yield: - * ```ts - * yield* asyncEventIterator( - * (handler) => service.onChange(handler), - * (handler) => service.offChange(handler), - * { initialValue: await service.getState() } - * ); - * ``` - */ -export async function* asyncEventIterator( - subscribe: (handler: (value: T) => void) => void, - unsubscribe: (handler: (value: T) => void) => void, - options?: { initialValue?: T } -): AsyncGenerator { - const queue: T[] = []; - let resolveNext: ((value: T) => void) | null = null; - let ended = false; - - const handler = (value: T) => { - if (ended) return; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - resolve(value); - } else { - queue.push(value); - } - }; - - subscribe(handler); - - try { - // Yield initial value if provided - if (options?.initialValue !== undefined) { - yield options.initialValue; - } - - while (!ended) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - yield await new Promise((resolve) => { - resolveNext = resolve; - }); - } - } - } finally { - ended = true; - unsubscribe(handler); - } -} - /** * Create an async event queue that can be pushed to from event handlers. * diff --git a/src/common/utils/capitalize.ts b/src/common/utils/capitalize.ts new file mode 100644 index 0000000000..6b18cd21c9 --- /dev/null +++ b/src/common/utils/capitalize.ts @@ -0,0 +1,7 @@ +/** + * Capitalize the first letter of a string. + */ +export function capitalize(str: string): string { + if (!str) return str; + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b7dcb5cf61..200de3aea3 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -9,7 +9,7 @@ import { } from "@/common/constants/muxGatewayOAuth"; import { Err, Ok } from "@/common/types/result"; import { resolveProviderCredentials } from "@/node/utils/providerRequirements"; -import { stripTrailingSlashes } from "@/node/utils/pathUtils"; +import { isPathInsideDir, stripTrailingSlashes } from "@/node/utils/pathUtils"; import { generateWorkspaceIdentity } from "@/node/services/workspaceTitleGenerator"; import type { UpdateStatus, @@ -148,14 +148,6 @@ function isErrnoWithCode(error: unknown, code: string): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === code); } -function isPathInsideDir(dirPath: string, filePath: string): boolean { - const resolvedDir = path.resolve(dirPath); - const resolvedFile = path.resolve(filePath); - const relative = path.relative(resolvedDir, resolvedFile); - - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function isTrustedProjectPath(context: ORPCContext, projectPath?: string | null): boolean { return isProjectTrusted(context.config, projectPath); } diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts index 96e522c952..4437147243 100644 --- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts +++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts @@ -6,6 +6,7 @@ import * as path from "node:path"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { DisposableProcess } from "@/node/utils/disposableExec"; +import { isPathInsideDir } from "@/node/utils/pathUtils"; const CLI_TIMEOUT_MS = 30_000; const PROCESS_CWD_TIMEOUT_MS = 5_000; @@ -432,11 +433,6 @@ function normalizeComparablePath(filePath: string): string { return process.platform === "win32" ? normalized.toLowerCase() : normalized; } -function isPathInsideDir(dirPath: string, filePath: string): boolean { - const relative = path.relative(dirPath, filePath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - async function readPositiveIntegerFile( readFileFn: typeof fsPromises.readFile, filePath: string diff --git a/src/node/services/tools/task_apply_git_patch.ts b/src/node/services/tools/task_apply_git_patch.ts index 41fa9008e5..1d5bd760fc 100644 --- a/src/node/services/tools/task_apply_git_patch.ts +++ b/src/node/services/tools/task_apply_git_patch.ts @@ -12,6 +12,7 @@ import { import { shellQuote } from "@/common/utils/shell"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { gitNoHooksPrefix } from "@/node/utils/gitNoHooksEnv"; +import { isPathInsideDir } from "@/node/utils/pathUtils"; import { getSubagentGitPatchMboxPath, markSubagentGitPatchArtifactApplied, @@ -77,14 +78,6 @@ function mergeNotes(...notes: Array): string | undefined { return parts.length > 0 ? parts.join("\n") : undefined; } -function isPathInsideDir(dirPath: string, filePath: string): boolean { - const resolvedDir = path.resolve(dirPath); - const resolvedFile = path.resolve(filePath); - const relative = path.relative(resolvedDir, resolvedFile); - - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - async function tryRevParseHead(params: { runtime: ToolConfiguration["runtime"]; cwd: string; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 1625e02cc0..2bb2b2dde6 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -12,6 +12,7 @@ import { Ok, Err } from "@/common/types/result"; import { askUserQuestionManager } from "@/node/services/askUserQuestionManager"; import { delegatedToolCallManager } from "@/node/services/delegatedToolCallManager"; import { log } from "@/node/services/log"; +import { isPathInsideDir } from "@/node/utils/pathUtils"; import { AgentSession } from "@/node/services/agentSession"; import type { HistoryService } from "@/node/services/historyService"; import type { AIService } from "@/node/services/aiService"; @@ -401,14 +402,6 @@ async function resetForkedSessionUsage( ); } -function isPathInsideDir(dirPath: string, filePath: string): boolean { - const resolvedDir = path.resolve(dirPath); - const resolvedFile = path.resolve(filePath); - const relative = path.relative(resolvedDir, resolvedFile); - - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function isPositiveInteger(value: unknown): value is number { return ( typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 diff --git a/src/node/utils/pathUtils.ts b/src/node/utils/pathUtils.ts index 236f2239af..4ec464da4c 100644 --- a/src/node/utils/pathUtils.ts +++ b/src/node/utils/pathUtils.ts @@ -106,3 +106,21 @@ export async function isGitRepository(projectPath: string): Promise { return false; } } + +/** + * Check whether `filePath` is equal to or nested inside `dirPath`. + * + * Both paths are resolved to absolute form first, so relative segments + * and missing trailing slashes are handled automatically. + * + * @example + * isPathInsideDir("/home/user/project", "/home/user/project/src/index.ts") // true + * isPathInsideDir("/home/user/project", "/home/user/other/file.ts") // false + */ +export function isPathInsideDir(dirPath: string, filePath: string): boolean { + const resolvedDir = path.resolve(dirPath); + const resolvedFile = path.resolve(filePath); + const relative = path.relative(resolvedDir, resolvedFile); + + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}