Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <span className="counter-nums text-muted">0</span>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Pluralize a repo count: "1 repo" / "3 repos". */
export function formatRepoCount(count: number): string {
return `${count} ${count === 1 ? "repo" : "repos"}`;
}
10 changes: 1 addition & 9 deletions src/browser/features/Analytics/DelegationChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down
15 changes: 0 additions & 15 deletions src/browser/hooks/useStableReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,6 @@ export function compareRecords<V>(
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<T>(a: Set<T>, b: Set<T>): 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.
Expand Down
10 changes: 2 additions & 8 deletions src/common/utils/ai/modelDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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"
*/
Expand Down
68 changes: 0 additions & 68 deletions src/common/utils/asyncEventIterator.ts
Original file line number Diff line number Diff line change
@@ -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<MyEvent>(
* (handler) => emitter.on('event', handler),
* (handler) => emitter.off('event', handler)
* );
* ```
*
* Or with initialValue for immediate first yield:
* ```ts
* yield* asyncEventIterator<MyState>(
* (handler) => service.onChange(handler),
* (handler) => service.offChange(handler),
* { initialValue: await service.getState() }
* );
* ```
*/
export async function* asyncEventIterator<T>(
subscribe: (handler: (value: T) => void) => void,
unsubscribe: (handler: (value: T) => void) => void,
options?: { initialValue?: T }
): AsyncGenerator<T> {
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<T>((resolve) => {
resolveNext = resolve;
});
}
}
} finally {
ended = true;
unsubscribe(handler);
}
}

/**
* Create an async event queue that can be pushed to from event handlers.
*
Expand Down
7 changes: 7 additions & 0 deletions src/common/utils/capitalize.ts
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 1 addition & 9 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 1 addition & 8 deletions src/node/services/tools/task_apply_git_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,14 +78,6 @@ function mergeNotes(...notes: Array<string | undefined>): 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;
Expand Down
9 changes: 1 addition & 8 deletions src/node/services/workspaceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/node/utils/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,21 @@ export async function isGitRepository(projectPath: string): Promise<boolean> {
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));
}
Loading