diff --git a/scripts/electron-builder/afterPack.cjs b/scripts/electron-builder/afterPack.cjs index c4ca053c..3ddcff23 100644 --- a/scripts/electron-builder/afterPack.cjs +++ b/scripts/electron-builder/afterPack.cjs @@ -159,6 +159,93 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) { return removedPaths; } +function findNodeModulesSequence(segments, sequence) { + for (let index = 0; index <= segments.length - sequence.length; index += 1) { + let matches = true; + for (let offset = 0; offset < sequence.length; offset += 1) { + if (segments[index + offset] !== sequence[offset]) { + matches = false; + break; + } + } + if (matches) { + return index; + } + } + return -1; +} + +function getKnownPrunableNativeArtifactRoot(appOutDir, filePath, targetPlatform, targetArch) { + if (targetPlatform !== 'win32') { + return null; + } + + const relativePath = path.relative(appOutDir, filePath); + const segments = relativePath.split(path.sep); + + const conptyIndex = findNodeModulesSequence(segments, [ + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + ]); + const conptyArchIndex = conptyIndex + 5; + const conptyArchDir = conptyIndex >= 0 ? segments[conptyArchIndex] : null; + if (conptyArchDir?.startsWith('win10-') && conptyArchDir !== `win10-${targetArch}`) { + return path.join(appOutDir, ...segments.slice(0, conptyArchIndex + 1)); + } + + return null; +} + +function isKnownAllowedNativeMismatch(relativePath, format, archs, targetPlatform) { + const normalizedPath = relativePath.split(path.sep).join('/'); + const ssh2PageantPath = 'node_modules/ssh2/util/pagent.exe'; + + return ( + targetPlatform === 'win32' && + (normalizedPath === ssh2PageantPath || normalizedPath.endsWith(`/${ssh2PageantPath}`)) && + format === 'pe' && + archs.size === 1 && + archs.has('ia32') + ); +} + +async function pruneKnownIncompatibleNativeArtifacts(appOutDir, targetPlatform, targetArch) { + const files = await walkFiles(appOutDir); + const rootsToRemove = new Set(); + + for (const filePath of files) { + const rootToRemove = getKnownPrunableNativeArtifactRoot( + appOutDir, + filePath, + targetPlatform, + targetArch + ); + if (!rootToRemove) { + continue; + } + + const metadata = await detectBinaryMetadata(filePath); + if (!metadata) { + continue; + } + + if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) { + continue; + } + + rootsToRemove.add(rootToRemove); + } + + const removedPaths = []; + for (const absolutePath of rootsToRemove) { + await fs.promises.rm(absolutePath, { recursive: true, force: true }); + removedPaths.push(absolutePath); + } + return removedPaths; +} + function mapMachOCpuType(cpuType) { switch (cpuType >>> 0) { case 0x00000007: @@ -335,6 +422,7 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) { const files = await walkFiles(appOutDir); for (const filePath of files) { + const relativePath = path.relative(appOutDir, filePath); const metadata = await detectBinaryMetadata(filePath); if (!metadata) { continue; @@ -344,8 +432,14 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) { continue; } + if ( + isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform) + ) { + continue; + } + mismatches.push({ - path: path.relative(appOutDir, filePath), + path: relativePath, format: metadata.format, archs: [...metadata.archs].sort(), }); @@ -358,7 +452,14 @@ async function afterPack(context) { const targetPlatform = context.electronPlatformName; const targetArch = getArchLabel(context.arch); - const removedPaths = await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch); + const removedPaths = [ + ...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)), + ...(await pruneKnownIncompatibleNativeArtifacts( + context.appOutDir, + targetPlatform, + targetArch + )), + ]; const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch); if (mismatches.length > 0) { @@ -383,9 +484,11 @@ module.exports._internal = { detectBinaryMetadata, getArchLabel, isBinaryCompatible, + isKnownAllowedNativeMismatch, parseElf, parseMachO, parsePortableExecutable, + pruneKnownIncompatibleNativeArtifacts, pruneNodePtyArtifacts, validateNativeBinaries, walkFiles, diff --git a/scripts/electron-builder/dist-invocations.cjs b/scripts/electron-builder/dist-invocations.cjs new file mode 100644 index 00000000..a8f7db1c --- /dev/null +++ b/scripts/electron-builder/dist-invocations.cjs @@ -0,0 +1,51 @@ +const PLATFORM_FLAGS = new Map([ + ['--mac', 'mac'], + ['-m', 'mac'], + ['--win', 'win'], + ['-w', 'win'], + ['--linux', 'linux'], + ['-l', 'linux'], +]); + +const PLATFORM_ARGS = { + mac: '--mac', + win: '--win', + linux: '--linux', +}; + +const LINUX_PACKAGE_NAME_OVERRIDES = [ + '--config.productName=Agent-Teams-UI', + '--config.linux.desktop.entry.Name=Agent Teams UI', +]; + +function buildElectronBuilderInvocations(argv) { + const targets = []; + const sharedArgs = []; + + for (const arg of argv) { + const target = PLATFORM_FLAGS.get(arg); + if (target) { + if (!targets.includes(target)) { + targets.push(target); + } + continue; + } + sharedArgs.push(arg); + } + + if (targets.length === 0) { + return [{ args: sharedArgs }]; + } + + return targets.map((target) => ({ + args: [ + PLATFORM_ARGS[target], + ...sharedArgs, + ...(target === 'linux' ? LINUX_PACKAGE_NAME_OVERRIDES : []), + ], + })); +} + +module.exports = { + buildElectronBuilderInvocations, +}; diff --git a/scripts/electron-builder/dist.mjs b/scripts/electron-builder/dist.mjs index e3c72615..cc98ed61 100644 --- a/scripts/electron-builder/dist.mjs +++ b/scripts/electron-builder/dist.mjs @@ -4,54 +4,9 @@ import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(import.meta.url); +const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs'); -const PLATFORM_FLAGS = new Map([ - ['--mac', 'mac'], - ['-m', 'mac'], - ['--win', 'win'], - ['-w', 'win'], - ['--linux', 'linux'], - ['-l', 'linux'], -]); - -const PLATFORM_ARGS = { - mac: '--mac', - win: '--win', - linux: '--linux', -}; - -const LINUX_PACKAGE_NAME_OVERRIDES = [ - '--config.productName=Agent-Teams-UI', - '--config.linux.desktop.entry.Name=Agent Teams UI', -]; - -export function buildElectronBuilderInvocations(argv) { - const targets = []; - const sharedArgs = []; - - for (const arg of argv) { - const target = PLATFORM_FLAGS.get(arg); - if (target) { - if (!targets.includes(target)) { - targets.push(target); - } - continue; - } - sharedArgs.push(arg); - } - - if (targets.length === 0) { - return [{ args: sharedArgs }]; - } - - return targets.map((target) => ({ - args: [ - PLATFORM_ARGS[target], - ...sharedArgs, - ...(target === 'linux' ? LINUX_PACKAGE_NAME_OVERRIDES : []), - ], - })); -} +export { buildElectronBuilderInvocations }; async function runElectronBuilder(args) { const cliPath = require.resolve('electron-builder/cli.js'); diff --git a/scripts/electron-builder/smokePackagedApp.cjs b/scripts/electron-builder/smokePackagedApp.cjs index 68594830..9892a072 100644 --- a/scripts/electron-builder/smokePackagedApp.cjs +++ b/scripts/electron-builder/smokePackagedApp.cjs @@ -5,6 +5,7 @@ const { spawn } = require('node:child_process'); const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000); const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000); +const SHUTDOWN_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_SHUTDOWN_TIMEOUT_MS ?? 5_000); const REQUIRED_LOG_MARKERS = ['renderer did-finish-load']; const FAILURE_PATTERNS = [ /Cannot find module/i, @@ -39,7 +40,10 @@ function findExecutable(bundlePath, platform) { if (platform === 'win32') { const executable = fs .readdirSync(bundlePath) - .find((entry) => entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall')); + .find( + (entry) => + entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall') + ); if (!executable) fail(`No .exe found in ${bundlePath}`); return path.join(bundlePath, executable); } @@ -66,6 +70,45 @@ function findExecutable(bundlePath, platform) { fail(`Unsupported platform: ${platform}`); } +function waitForProcessClose(child, exitPromise, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true); + } + + let timeoutId; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(false), timeoutMs); + }); + return Promise.race([exitPromise.then(() => true), timeoutPromise]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); +} + +async function terminateChild(child, exitPromise, platform) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + if (platform === 'win32' && child.pid) { + await new Promise((resolve) => { + const killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + }); + killer.once('error', resolve); + killer.once('close', resolve); + }); + } else { + child.kill(); + } + + const closed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS); + if (!closed && child.exitCode === null && child.signalCode === null) { + throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`); + } +} + async function main() { const [bundlePathArg, platform] = process.argv.slice(2); if (!bundlePathArg || !platform) { @@ -100,7 +143,7 @@ async function main() { let startupSeenAt = null; while (Date.now() < deadline) { if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) { - child.kill(); + await terminateChild(child, exitPromise, platform); fail('Detected startup failure pattern', log); } @@ -109,7 +152,7 @@ async function main() { } if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) { - child.kill(); + await terminateChild(child, exitPromise, platform); console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`); return; } @@ -119,11 +162,14 @@ async function main() { new Promise((resolve) => setTimeout(() => resolve(null), 250)), ]); if (exit) { - fail(`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, log); + fail( + `Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, + log + ); } } - child.kill(); + await terminateChild(child, exitPromise, platform); fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log); } diff --git a/scripts/electron-builder/verifyBundle.cjs b/scripts/electron-builder/verifyBundle.cjs index 29ec81db..c4e68520 100644 --- a/scripts/electron-builder/verifyBundle.cjs +++ b/scripts/electron-builder/verifyBundle.cjs @@ -4,6 +4,18 @@ const afterPackModule = require('./afterPack.cjs'); const { validateNativeBinaries } = afterPackModule._internal; +function isAllowedPostPackMismatch(mismatch, platform, arch) { + const relativePath = mismatch.path.split(path.sep).join('/'); + return ( + platform === 'win32' && + arch === 'x64' && + relativePath === 'resources/elevate.exe' && + mismatch.format === 'pe' && + mismatch.archs.length === 1 && + mismatch.archs[0] === 'ia32' + ); +} + async function main() { const [bundlePathArg, platform, arch] = process.argv.slice(2); @@ -14,16 +26,22 @@ async function main() { const bundlePath = path.resolve(bundlePathArg); const mismatches = await validateNativeBinaries(bundlePath, platform, arch); + const blockingMismatches = mismatches.filter( + (mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch) + ); - if (mismatches.length === 0) { - console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`); + if (blockingMismatches.length === 0) { + const allowedCount = mismatches.length - blockingMismatches.length; + const suffix = + allowedCount > 0 ? ` (${allowedCount} allowed post-pack helper mismatch ignored)` : ''; + console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}${suffix}`); return; } console.error( - `[verifyBundle] Found ${mismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}` + `[verifyBundle] Found ${blockingMismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}` ); - for (const mismatch of mismatches.slice(0, 50)) { + for (const mismatch of blockingMismatches.slice(0, 50)) { console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`); } process.exit(1); diff --git a/scripts/smoke/agent-attachments-smoke.mjs b/scripts/smoke/agent-attachments-smoke.mjs index dc83b686..13f323d2 100644 --- a/scripts/smoke/agent-attachments-smoke.mjs +++ b/scripts/smoke/agent-attachments-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -326,12 +327,93 @@ function parseArgs(argv) { return { all, jsonPath, list, selected }; } +function hasPathSeparator(value) { + return value.includes('/') || value.includes('\\'); +} + +function resolveWindowsSpawnBinary(binary) { + if (process.platform !== 'win32') { + return binary; + } + + if (hasPathSeparator(binary)) { + if (!path.extname(binary) && existsSync(`${binary}.cmd`)) { + return `${binary}.cmd`; + } + return binary; + } + + const whereResult = spawnSync('where.exe', [binary], { + encoding: 'utf8', + windowsHide: true, + }); + if (whereResult.status !== 0 || !whereResult.stdout) { + return binary; + } + + const candidates = whereResult.stdout + .split(/\r?\n/) + .map((candidate) => candidate.trim()) + .filter(Boolean); + const extensionlessShim = candidates.find( + (candidate) => !path.extname(candidate) && existsSync(`${candidate}.cmd`) + ); + if (extensionlessShim) { + return `${extensionlessShim}.cmd`; + } + return ( + candidates.find((candidate) => /\.exe$/i.test(candidate)) ?? + candidates.find((candidate) => /\.(?:cmd|bat)$/i.test(candidate)) ?? + candidates[0] ?? + binary + ); +} + +function quoteWindowsCmdArg(value) { + const text = String(value); + if (text.length === 0) { + return '""'; + } + if (!/[ \t\r\n"&|<>^()%!]/.test(text)) { + return text; + } + return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`; +} + +function buildSpawnInvocation(command) { + if (process.platform !== 'win32') { + return { + bin: command.bin, + args: command.args, + options: { windowsHide: true }, + }; + } + + const resolvedBin = resolveWindowsSpawnBinary(command.bin); + if (/\.(?:cmd|bat)$/i.test(resolvedBin)) { + const commandLine = [resolvedBin, ...command.args].map(quoteWindowsCmdArg).join(' '); + return { + bin: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', commandLine], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }; + } + + return { + bin: resolvedBin, + args: command.args, + options: { windowsHide: true }, + }; +} + function runCommand(command) { return new Promise((resolve) => { - const child = spawn(command.bin, command.args, { + const spawnInvocation = buildSpawnInvocation(command); + const child = spawn(spawnInvocation.bin, spawnInvocation.args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, cwd: command.cwd, + ...spawnInvocation.options, }); let stdout = ''; let stderr = ''; diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts index 28d8b880..0b3be39c 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -1,4 +1,5 @@ import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; +import { normalizePathForComparison } from '@shared/utils/platformPath'; import type { DashboardRecentProject } from '@features/recent-projects/contracts'; @@ -34,7 +35,7 @@ function normalizeHistoryPath(projectPath: string): string | null { normalizedPath = normalizedPath.slice(0, -1); } } - return normalizedPath; + return normalizedPath ? normalizePathForComparison(normalizedPath) : null; } function foldHistoryPath(projectPath: string): string { diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts index 52ce0927..691f3603 100644 --- a/src/main/services/editor/FileSearchService.ts +++ b/src/main/services/editor/FileSearchService.ts @@ -292,9 +292,7 @@ export class FileSearchService { subdirs.push(fullPath); } else if (entry.isFile()) { if (IGNORED_FILES.has(entry.name)) continue; - const relativePath = fullPath.startsWith(projectRoot) - ? fullPath.slice(projectRoot.length + 1) - : entry.name; + const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join('/'); files.push({ path: fullPath, name: entry.name, relativePath }); } } diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts index 48e38d43..9bb7f723 100644 --- a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts +++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts @@ -2,6 +2,8 @@ * Build a directory tree structure from CLAUDE.md injections. */ +import { getRelativePathWithinPrefix } from '@shared/utils/platformPath'; + import type { TreeNode } from './types'; import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection'; @@ -15,12 +17,9 @@ export function buildDirectoryTree( const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() }; for (const injection of injections) { - let relativePath = injection.path; - if (projectRoot && relativePath.startsWith(projectRoot)) { - relativePath = relativePath.slice(projectRoot.length); - if (relativePath.startsWith('/') || relativePath.startsWith('\\')) - relativePath = relativePath.slice(1); - } + const relativePath = projectRoot + ? getRelativePathWithinPrefix(projectRoot, injection.path) ?? injection.path + : injection.path; const parts = relativePath.split(/[\\/]/); let current = root; diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx index 7ed49cf7..4b1017b8 100644 --- a/src/renderer/components/chat/viewers/FileLink.tsx +++ b/src/renderer/components/chat/viewers/FileLink.tsx @@ -57,21 +57,20 @@ export function resolveFileLinkPath(filePath: string, projectPath: string): stri function normalizePathSegments(filePath: string): string { const hasBackslash = filePath.includes('\\') && !filePath.includes('/'); const separator = hasBackslash ? '\\' : '/'; - const normalized = filePath.replace(/[/\\]+/g, separator); let prefix = ''; - let body = normalized; + let body = filePath; - const driveMatch = /^([A-Za-z]:)[\\/]/.exec(normalized); + const driveMatch = /^([A-Za-z]:)[\\/]/.exec(filePath); if (driveMatch) { prefix = `${driveMatch[1]}${separator}`; - body = normalized.slice(prefix.length); - } else if (normalized.startsWith(`${separator}${separator}`)) { + body = filePath.slice(driveMatch[0].length); + } else if (filePath.startsWith('\\\\') || filePath.startsWith('//')) { prefix = `${separator}${separator}`; - body = normalized.slice(2); - } else if (normalized.startsWith(separator)) { + body = filePath.slice(2); + } else if (filePath.startsWith('/') || filePath.startsWith('\\')) { prefix = separator; - body = normalized.slice(1); + body = filePath.slice(1); } const segments: string[] = []; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 90faaf38..cca878d4 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -27,6 +27,7 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { resolveFilePath } from '@renderer/store/utils/pathResolution'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { nameColorSet } from '@renderer/utils/projectColor'; import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; @@ -318,9 +319,8 @@ function isAllowedElement(element: { tagName: string }): boolean { } /** Resolve a relative path to an absolute path given a base directory */ -function resolveRelativePath(relativeSrc: string, baseDir: string): string { - const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc; - return `${baseDir}/${cleaned}`; +export function resolveRelativePath(relativeSrc: string, baseDir: string): string { + return resolveFilePath(baseDir, relativeSrc); } // ============================================================================= diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index 0e653f52..dd0ef9a1 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -254,7 +254,9 @@ export const GeneralSection = ({ const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude'; const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude'; const isWindowsStyleDefaultPath = - /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\'); + /^[a-zA-Z]:[/\\]/.test(defaultClaudeRootPath) || + defaultClaudeRootPath.startsWith('\\\\') || + defaultClaudeRootPath.startsWith('//'); const isElectron = useMemo(() => isElectronMode(), []); diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx index 5a795ff9..befabb3f 100644 --- a/src/renderer/components/team/editor/EditorContextMenu.tsx +++ b/src/renderer/components/team/editor/EditorContextMenu.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useRef, useState } from 'react'; import * as ContextMenu from '@radix-ui/react-context-menu'; -import { lastSeparatorIndex } from '@shared/utils/platformPath'; +import { getRelativePathWithinPrefix, lastSeparatorIndex } from '@shared/utils/platformPath'; import { ClipboardCopy, FilePlus, @@ -87,6 +87,8 @@ export const EditorContextMenu = ({ ? target.path : target.path.substring(0, lastSeparatorIndex(target.path)) : null; + const targetRelativePath = + projectPath && target ? getRelativePathWithinPrefix(projectPath, target.path) : null; return ( @@ -154,12 +156,11 @@ export const EditorContextMenu = ({ Copy Path - {projectPath && target.path.startsWith(projectPath) && ( + {targetRelativePath !== null && ( { - const relative = target.path.slice(projectPath.length + 1); - void navigator.clipboard.writeText(relative); + void navigator.clipboard.writeText(targetRelativePath); }} > diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx index 4253a22d..81d57b58 100644 --- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx +++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx @@ -10,7 +10,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath'; +import { + getBasename, + getRelativePathWithinPrefix, + lastSeparatorIndex, +} from '@shared/utils/platformPath'; import { Loader2, Search, X } from 'lucide-react'; import { FileIcon } from './FileIcon'; @@ -146,7 +150,7 @@ export const SearchInFilesPanel = ({ const getRelativePath = useCallback( (filePath: string) => { - return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath; + return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath; }, [projectPath] ); diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 15b8541e..f5a8449c 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -13,6 +13,7 @@ import { onQuickOpenCacheInvalidated, setQuickOpenCache, } from '@renderer/utils/quickOpenCache'; +import { joinPath, splitPath } from '@shared/utils/platformPath'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { QuickOpenFile } from '@shared/types/editor'; @@ -53,12 +54,12 @@ export function formatFileMentionPath(relativePath: string): string { * Extracts unique directories from a list of file paths. * Returns directories sorted by depth (shallower first), then alphabetically. */ -function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] { +export function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] { const dirSet = new Set(); for (const f of files) { // Walk up the directory chain from each file's relative path - const parts = f.relativePath.split('/'); + const parts = splitPath(f.relativePath); // Remove the file name — keep only directory segments for (let i = 1; i < parts.length; i++) { dirSet.add(parts.slice(0, i).join('/')); @@ -67,19 +68,19 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive const folders: DerivedFolder[] = []; for (const relDir of dirSet) { - const segments = relDir.split('/'); + const segments = splitPath(relDir); const name = segments[segments.length - 1]; folders.push({ name, relativePath: relDir + '/', - absolutePath: projectPath + '/' + relDir, + absolutePath: joinPath(projectPath, ...splitPath(relDir)), }); } // Sort: shallower first, then alphabetically folders.sort((a, b) => { - const depthA = a.relativePath.split('/').length; - const depthB = b.relativePath.split('/').length; + const depthA = splitPath(a.relativePath).length; + const depthB = splitPath(b.relativePath).length; if (depthA !== depthB) return depthA - depthB; return a.relativePath.localeCompare(b.relativePath); }); @@ -94,13 +95,14 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] { if (!query || files.length === 0) return []; - const lower = query.toLowerCase(); + const lower = query.replace(/\\/g, '/').toLowerCase(); const results: MentionSuggestion[] = []; for (const f of files) { if (results.length >= MAX_FILE_SUGGESTIONS) break; - if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) { + const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase(); + if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) { results.push({ id: `file:${f.path}`, name: f.name, @@ -127,14 +129,15 @@ export function filterFolderSuggestions( if (!query || folders.length === 0) return []; // Strip trailing slash from query for matching (e.g. "ui/" -> "ui") - const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query; - const lower = cleanQuery.toLowerCase(); + const cleanQuery = query.endsWith('/') || query.endsWith('\\') ? query.slice(0, -1) : query; + const lower = cleanQuery.replace(/\\/g, '/').toLowerCase(); const results: MentionSuggestion[] = []; for (const f of folders) { if (results.length >= MAX_FOLDER_SUGGESTIONS) break; - if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) { + const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase(); + if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) { results.push({ id: `folder:${f.absolutePath}`, name: f.name + '/', diff --git a/src/renderer/store/utils/pathResolution.ts b/src/renderer/store/utils/pathResolution.ts index 7d520af8..43b58f0f 100644 --- a/src/renderer/store/utils/pathResolution.ts +++ b/src/renderer/store/utils/pathResolution.ts @@ -2,6 +2,8 @@ * Path resolution utilities for the store. */ +import { stripTrailingSeparators } from '@shared/utils/platformPath'; + /** * Resolves a relative path against a base path, handling various path formats. * Handles: @@ -17,7 +19,7 @@ export function resolveFilePath(base: string, relativePath: string): string { return relativePath; } - const cleanBase = trimTrailingSeparator(base); + const cleanBase = stripTrailingSeparators(base); // Handle @ prefix (file mention marker) - strip it if present let cleanRelative = relativePath; @@ -32,21 +34,22 @@ export function resolveFilePath(base: string, relativePath: string): string { } // Handle ./ prefix (current directory) - if (cleanRelative.startsWith('./')) { + if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) { cleanRelative = cleanRelative.slice(2); } // Handle ../ prefixes (parent directory) const separator = cleanBase.includes('\\') ? '\\' : '/'; - const hasUnixRoot = cleanBase.startsWith('/'); - const hasUncRoot = cleanBase.startsWith('\\\\'); + const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//'); + const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/'); + const minRootParts = hasUncRoot ? 2 : 1; const normalizedRelative = normalizeSeparators(cleanRelative, separator); const baseParts = splitPath(cleanBase); let remainingRelative = normalizedRelative; while (remainingRelative.startsWith(`..${separator}`)) { remainingRelative = remainingRelative.slice(3); - if (baseParts.length > 1) { + if (baseParts.length > minRootParts) { baseParts.pop(); } } @@ -56,8 +59,8 @@ export function resolveFilePath(base: string, relativePath: string): string { if (hasUnixRoot && !normalizedBase.startsWith('/')) { normalizedBase = `/${normalizedBase}`; } - if (hasUncRoot && !normalizedBase.startsWith('\\\\')) { - normalizedBase = `\\\\${normalizedBase}`; + if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) { + normalizedBase = `${separator}${separator}${normalizedBase}`; } return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; } @@ -66,18 +69,6 @@ function isAbsolutePath(input: string): boolean { return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input); } -function trimTrailingSeparator(input: string): string { - let end = input.length; - while (end > 0) { - const char = input[end - 1]; - if (char !== '/' && char !== '\\') { - break; - } - end--; - } - return input.slice(0, end); -} - function normalizeSeparators(input: string, separator: '/' | '\\'): string { let output = ''; let prevWasSeparator = false; diff --git a/src/renderer/utils/claudeMdTracker.ts b/src/renderer/utils/claudeMdTracker.ts index 5510d570..ac045070 100644 --- a/src/renderer/utils/claudeMdTracker.ts +++ b/src/renderer/utils/claudeMdTracker.ts @@ -8,8 +8,11 @@ */ import { + isPathPrefix, lastSeparatorIndex, + normalizePathForComparison, splitPath as splitPathCrossPlatform, + stripTrailingSeparators, } from '@shared/utils/platformPath'; import { extractFileReferences } from './groupTransformer'; @@ -64,7 +67,14 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string { * Check if a path is absolute (starts with /). */ function isAbsolutePath(path: string): boolean { - return path.startsWith('/') || path.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(path); + return ( + path.startsWith('/') || + path.startsWith('~/') || + path.startsWith('~\\') || + path === '~' || + path.startsWith('\\\\') || + /^[a-zA-Z]:[\\/]/.test(path) + ); } /** @@ -82,7 +92,7 @@ function joinPaths(base: string, relative: string): string { } // Remove trailing slash from base if present - const cleanBase = trimTrailingSeparator(base); + const cleanBase = stripTrailingSeparators(base); // Handle @ prefix (file mention marker) - strip it if present let cleanRelative = relative; @@ -91,20 +101,21 @@ function joinPaths(base: string, relative: string): string { } // Handle ./ prefix (current directory) - if (cleanRelative.startsWith('./')) { + if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) { cleanRelative = cleanRelative.slice(2); } // Handle ../ prefixes (parent directory) const separator = cleanBase.includes('\\') ? '\\' : '/'; - const hasUnixRoot = cleanBase.startsWith('/'); - const hasUncRoot = cleanBase.startsWith('\\\\'); + const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//'); + const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/'); + const minRootParts = hasUncRoot ? 2 : 1; const normalizedRelative = normalizeSeparators(cleanRelative, separator); const baseParts = splitPath(cleanBase); let remainingRelative = normalizedRelative; while (remainingRelative.startsWith(`..${separator}`)) { remainingRelative = remainingRelative.slice(3); - if (baseParts.length > 1) { + if (baseParts.length > minRootParts) { baseParts.pop(); } } @@ -114,24 +125,12 @@ function joinPaths(base: string, relative: string): string { if (hasUnixRoot && !normalizedBase.startsWith('/')) { normalizedBase = `/${normalizedBase}`; } - if (hasUncRoot && !normalizedBase.startsWith('\\\\')) { - normalizedBase = `\\\\${normalizedBase}`; + if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) { + normalizedBase = `${separator}${separator}${normalizedBase}`; } return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; } -function trimTrailingSeparator(input: string): string { - let end = input.length; - while (end > 0) { - const char = input[end - 1]; - if (char !== '/' && char !== '\\') { - break; - } - end--; - } - return input.slice(0, end); -} - function normalizeSeparators(input: string, separator: '/' | '\\'): string { let output = ''; let prevWasSeparator = false; @@ -158,7 +157,19 @@ function splitPath(input: string): string[] { } function normalizeForComparison(input: string): string { - return input.replace(/\\/g, '/'); + return normalizePathForComparison(input); +} + +function createSeenPathSet(paths: string[]): Set { + return new Set(paths.map(normalizeForComparison)); +} + +function hasSeenPath(seenPaths: Set, path: string): boolean { + return seenPaths.has(normalizeForComparison(path)); +} + +function rememberPath(seenPaths: Set, path: string): void { + seenPaths.add(normalizeForComparison(path)); } /** @@ -183,11 +194,7 @@ export function getParentDirectory(dirPath: string): string | null { * Check if dirPath is at or above stopPath in the directory tree. */ function isAtOrAbove(dirPath: string, stopPath: string): boolean { - const normDir = normalizeForComparison(dirPath).replace(/\/$/, ''); - const normStop = normalizeForComparison(stopPath).replace(/\/$/, ''); - - // dirPath is at or above stopPath if stopPath starts with dirPath - return normStop === normDir || normStop.startsWith(normDir + '/'); + return isPathPrefix(dirPath, stopPath); } // ============================================================================= @@ -485,7 +492,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats } = params; const newInjections: ClaudeMdInjection[] = []; - const previousPaths = new Set(previousInjections.map((inj) => inj.path)); + const previousPaths = createSeenPathSet(previousInjections.map((inj) => inj.path)); // For the first group, add global injections // Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel @@ -493,9 +500,9 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats if (isFirstGroup) { const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData); for (const injection of globalInjections) { - if (!previousPaths.has(injection.path)) { + if (!hasSeenPath(previousPaths, injection.path)) { newInjections.push(injection); - previousPaths.add(injection.path); + rememberPath(previousPaths, injection.path); } } } @@ -526,7 +533,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats for (const claudeMdPath of claudeMdPaths) { // Skip if already seen - if (previousPaths.has(claudeMdPath)) { + if (hasSeenPath(previousPaths, claudeMdPath)) { continue; } @@ -546,7 +553,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats // Create directory injection const injection = createDirectoryInjection(claudeMdPath, turnGroupId); newInjections.push(injection); - previousPaths.add(claudeMdPath); + rememberPath(previousPaths, claudeMdPath); } } diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index 78eb4986..ec8ecd8e 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -9,6 +9,7 @@ * This builds on claudeMdTracker.ts and extends it to track all context sources. */ +import { normalizePathForComparison, stripTrailingSeparators } from '@shared/utils/platformPath'; import { estimateTokens } from '@shared/utils/tokenFormatting'; import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection'; @@ -474,7 +475,7 @@ function joinPaths(base: string, relative: string): string { return relative; } - const cleanBase = trimTrailingSeparator(base); + const cleanBase = stripTrailingSeparators(base); // Handle @ prefix (file mention marker) - strip it if present let cleanRelative = relative; @@ -483,20 +484,21 @@ function joinPaths(base: string, relative: string): string { } // Handle ./ prefix (current directory) - if (cleanRelative.startsWith('./')) { + if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) { cleanRelative = cleanRelative.slice(2); } // Handle ../ prefixes (parent directory) const separator = cleanBase.includes('\\') ? '\\' : '/'; - const hasUnixRoot = cleanBase.startsWith('/'); - const hasUncRoot = cleanBase.startsWith('\\\\'); + const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//'); + const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/'); + const minRootParts = hasUncRoot ? 2 : 1; const normalizedRelative = normalizeSeparators(cleanRelative, separator); const baseParts = splitPath(cleanBase); let remainingRelative = normalizedRelative; while (remainingRelative.startsWith(`..${separator}`)) { remainingRelative = remainingRelative.slice(3); - if (baseParts.length > 1) { + if (baseParts.length > minRootParts) { baseParts.pop(); } } @@ -506,24 +508,12 @@ function joinPaths(base: string, relative: string): string { if (hasUnixRoot && !normalizedBase.startsWith('/')) { normalizedBase = `/${normalizedBase}`; } - if (hasUncRoot && !normalizedBase.startsWith('\\\\')) { - normalizedBase = `\\\\${normalizedBase}`; + if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) { + normalizedBase = `${separator}${separator}${normalizedBase}`; } return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; } -function trimTrailingSeparator(input: string): string { - let end = input.length; - while (end > 0) { - const char = input[end - 1]; - if (char !== '/' && char !== '\\') { - break; - } - end--; - } - return input.slice(0, end); -} - function normalizeSeparators(input: string, separator: '/' | '\\'): string { let output = ''; let prevWasSeparator = false; @@ -567,7 +557,50 @@ function splitPath(input: string): string[] { } function normalizeForComparison(input: string): string { - return input.replace(/\\/g, '/'); + return normalizePathForComparison(input); +} + +function createSeenPathSet(paths: string[]): Set { + return new Set(paths.map(normalizeForComparison)); +} + +function hasSeenPath(seenPaths: Set, path: string): boolean { + return seenPaths.has(normalizeForComparison(path)); +} + +function rememberPath(seenPaths: Set, path: string): void { + seenPaths.add(normalizeForComparison(path)); +} + +function getRecordValueByPath( + record: Record | undefined, + path: string +): T | undefined { + if (!record) return undefined; + const exact = record[path]; + if (exact !== undefined) return exact; + + const normalizedPath = normalizeForComparison(path); + for (const [key, value] of Object.entries(record)) { + if (normalizeForComparison(key) === normalizedPath) { + return value; + } + } + return undefined; +} + +function getMapValueByPath(map: Map | undefined, path: string): T | undefined { + if (!map) return undefined; + const exact = map.get(path); + if (exact !== undefined) return exact; + + const normalizedPath = normalizeForComparison(path); + for (const [key, value] of map.entries()) { + if (normalizeForComparison(key) === normalizedPath) { + return value; + } + } + return undefined; } /** @@ -604,7 +637,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { } = params; const newInjections: ContextInjection[] = []; - const previousPaths = new Set( + const previousPaths = createSeenPathSet( previousInjections .filter( (inj): inj is ClaudeMdContextInjection | MentionedFileInjection => @@ -620,9 +653,9 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { if (isFirstGroup) { const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData); for (const injection of globalInjections) { - if (!previousPaths.has(injection.path)) { + if (!hasSeenPath(previousPaths, injection.path)) { newInjections.push(wrapClaudeMdInjection(injection)); - previousPaths.add(injection.path); + rememberPath(previousPaths, injection.path); } } } @@ -654,7 +687,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { for (const claudeMdPath of claudeMdPaths) { // Skip if already seen - if (previousPaths.has(claudeMdPath)) { + if (hasSeenPath(previousPaths, claudeMdPath)) { continue; } @@ -674,7 +707,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { // Only include directory CLAUDE.md files that exist (validated via directoryTokenData) // If directoryTokenData is provided and doesn't contain this path, the file doesn't exist if (directoryTokenData) { - const fileInfo = directoryTokenData[claudeMdPath]; + const fileInfo = getRecordValueByPath(directoryTokenData, claudeMdPath); if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) { // File doesn't exist or has no content - skip it continue; @@ -683,12 +716,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { const injection = createDirectoryInjection(claudeMdPath, turnGroupId); injection.estimatedTokens = fileInfo.estimatedTokens; newInjections.push(wrapClaudeMdInjection(injection)); - previousPaths.add(claudeMdPath); + rememberPath(previousPaths, claudeMdPath); } else { // Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior) const injection = createDirectoryInjection(claudeMdPath, turnGroupId); newInjections.push(wrapClaudeMdInjection(injection)); - previousPaths.add(claudeMdPath); + rememberPath(previousPaths, claudeMdPath); } } } @@ -704,12 +737,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { : joinPaths(projectRoot, fileRef.path); // Skip if already seen - if (previousPaths.has(absolutePath)) { + if (hasSeenPath(previousPaths, absolutePath)) { continue; } // Check if we have token data for this file - const fileInfo = mentionedFileTokenData?.get(absolutePath); + const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath); // Only include files that exist and are under the token limit if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) { @@ -723,7 +756,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { }); newInjections.push(mentionedFileInjection); - previousPaths.add(absolutePath); + rememberPath(previousPaths, absolutePath); } } } @@ -736,11 +769,11 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { ? fileRef.path : joinPaths(projectRoot, fileRef.path); - if (previousPaths.has(absolutePath)) { + if (hasSeenPath(previousPaths, absolutePath)) { continue; } - const fileInfo = mentionedFileTokenData?.get(absolutePath); + const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath); if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) { const mentionedFileInjection = createMentionedFileInjection({ @@ -753,7 +786,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { }); newInjections.push(mentionedFileInjection); - previousPaths.add(absolutePath); + rememberPath(previousPaths, absolutePath); } } diff --git a/src/renderer/utils/pathDisplay.ts b/src/renderer/utils/pathDisplay.ts index ccdd6808..a9955671 100644 --- a/src/renderer/utils/pathDisplay.ts +++ b/src/renderer/utils/pathDisplay.ts @@ -9,7 +9,7 @@ * Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute). */ -import { splitPath } from '@shared/utils/platformPath'; +import { getRelativePathWithinPrefix, splitPath } from '@shared/utils/platformPath'; function isWindowsAbsolutePath(input: string): boolean { return /^[A-Za-z]:[/\\]/.test(input) || input.startsWith('\\\\') || input.startsWith('//'); @@ -38,15 +38,9 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe // 1. Make relative to project root if (projectRoot) { - const root = projectRoot.replace(/[/\\]$/, ''); - const caseInsensitive = isWindowsAbsolutePath(p) || isWindowsAbsolutePath(root); - const pathForCompare = caseInsensitive ? p.toLowerCase() : p; - const rootForCompare = caseInsensitive ? root.toLowerCase() : root; - if ( - pathForCompare.startsWith(rootForCompare + '/') || - pathForCompare.startsWith(rootForCompare + '\\') - ) { - p = p.slice(root.length + 1); + const relativePath = getRelativePathWithinPrefix(projectRoot, p); + if (relativePath) { + p = relativePath; } } @@ -54,7 +48,7 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe p = p .replace(/^\/Users\/[^/]+/, '~') .replace(/^\/home\/[^/]+/, '~') - .replace(/^[A-Z]:\\Users\\[^\\]+/i, '~'); + .replace(/^[A-Z]:[/\\]Users[/\\][^/\\]+/i, '~'); // 3. If short enough, return as-is if (p.length <= maxLength) return p; @@ -84,7 +78,7 @@ function inferHomeDir(projectRoot: string): string | null { const match = /^(\/Users\/[^/]+)/.exec(projectRoot) ?? /^(\/home\/[^/]+)/.exec(projectRoot) ?? - /^([A-Z]:\\Users\\[^\\]+)/i.exec(projectRoot); + /^([A-Z]:[/\\]Users[/\\][^/\\]+)/i.exec(projectRoot); return match?.[1] ?? null; } @@ -122,11 +116,7 @@ export function formatProjectPath(path: string): string { } function isWindowsUserPath(input: string): boolean { - if (input.length < 10) return false; - const drive = input.charCodeAt(0); - const hasDriveLetter = - ((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':'; - return hasDriveLetter && input.slice(2, 9).toLowerCase() === '\\users\\'; + return /^[A-Z]:[/\\]Users[/\\]/i.test(input); } export function resolveAbsolutePath(filePath: string, projectRoot?: string): string { diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts index 82f0c0aa..03eacb2f 100644 --- a/src/shared/utils/platformPath.ts +++ b/src/shared/utils/platformPath.ts @@ -78,6 +78,7 @@ export function joinPath(base: string, ...segments: string[]): string { export function isPathPrefix(prefix: string, fullPath: string): boolean { const p = stripTrailingSeparators(normalizePathForComparison(prefix)); const f = stripTrailingSeparators(normalizePathForComparison(fullPath)); + if (!p) return false; if (f === p) return true; // Root prefixes are special: p already ends with "/" ("/" or "c:/"). if (p === '/') return f.startsWith('/'); @@ -85,6 +86,26 @@ export function isPathPrefix(prefix: string, fullPath: string): boolean { return f.startsWith(p + '/'); } +/** Return fullPath relative to prefix when fullPath is inside prefix, preserving fullPath style. */ +export function getRelativePathWithinPrefix(prefix: string, fullPath: string): string | null { + const cleanPrefix = stripTrailingSeparators(prefix); + if (!isPathPrefix(cleanPrefix, fullPath)) { + return null; + } + + const normalizedPrefix = stripTrailingSeparators(normalizePathForComparison(cleanPrefix)); + const normalizedFullPath = stripTrailingSeparators(normalizePathForComparison(fullPath)); + if (normalizedFullPath === normalizedPrefix) { + return ''; + } + + let relativePath = fullPath.slice(cleanPrefix.length); + while (relativePath.startsWith('/') || relativePath.startsWith('\\')) { + relativePath = relativePath.slice(1); + } + return relativePath; +} + /** Get the last segment (filename) from a path. */ export function getBasename(filePath: string): string { const parts = splitPath(filePath); diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts index 147ae8b6..98ddb38f 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts @@ -129,6 +129,20 @@ describe('recentProjectOpenHistory', () => { ).toBe(0); }); + it('collapses Windows drive-case and separator variants', () => { + recordRecentProjectOpenPaths(['C:\\Work\\Repo'], 5_000); + recordRecentProjectOpenPaths(['c:/work/repo/'], 8_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: 'C:/WORK/REPO', + associatedPaths: ['C:/WORK/REPO'], + }) + ) + ).toBe(8_000); + }); + it('does not record generated ephemeral project paths', () => { recordRecentProjectOpenPaths( ['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'], @@ -152,4 +166,9 @@ describe('recentProjectOpenHistory', () => { ) ).toBe(10_000); }); + + it('ignores blank project paths without throwing', () => { + expect(() => recordRecentProjectOpenPaths([' '], 10_000)).not.toThrow(); + expect(getRecentProjectLastOpenedAt(makeProject({ primaryPath: ' ', associatedPaths: [' '] }))).toBe(0); + }); }); diff --git a/test/main/build/electronBuilderAfterPack.test.ts b/test/main/build/electronBuilderAfterPack.test.ts index 7b8428e4..8835dbfc 100644 --- a/test/main/build/electronBuilderAfterPack.test.ts +++ b/test/main/build/electronBuilderAfterPack.test.ts @@ -45,8 +45,8 @@ function createElfBuffer(arch: 'arm64' | 'x64'): Buffer { return buffer; } -function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer { - const machine = arch === 'arm64' ? 0xaa64 : 0x8664; +function createPortableExecutableBuffer(arch: 'arm64' | 'ia32' | 'x64'): Buffer { + const machine = arch === 'arm64' ? 0xaa64 : arch === 'ia32' ? 0x014c : 0x8664; const buffer = Buffer.alloc(256); buffer[0] = 0x4d; buffer[1] = 0x5a; @@ -224,4 +224,72 @@ describe('electron-builder afterPack', () => { ) ).toBe(false); }); + + it('accepts a clean x64 Windows bundle with optional standalone helper binaries', async () => { + const tempDir = createTempDir(); + tempDirs.push(tempDir); + + writeFile(path.join(tempDir, 'Agent Teams UI.exe'), createPortableExecutableBuffer('x64')); + writeFile( + path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'build', + 'Release', + 'pty.node' + ), + createPortableExecutableBuffer('x64') + ); + const pageantPath = path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'ssh2', + 'util', + 'pagent.exe' + ); + const armConptyDir = path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + '1.23.251008001', + 'win10-arm64' + ); + writeFile(pageantPath, createPortableExecutableBuffer('ia32')); + writeFile(path.join(armConptyDir, 'conpty.dll'), createPortableExecutableBuffer('arm64')); + writeFile(path.join(armConptyDir, 'OpenConsole.exe'), createPortableExecutableBuffer('arm64')); + + await afterPackModule({ + appOutDir: tempDir, + electronPlatformName: 'win32', + arch: 1, + }); + + expect(fs.existsSync(pageantPath)).toBe(true); + expect(fs.existsSync(armConptyDir)).toBe(false); + }); + + it('still reports unrelated ia32 Windows binaries in an x64 bundle', async () => { + const tempDir = createTempDir(); + tempDirs.push(tempDir); + + const badBinaryPath = path.join(tempDir, 'resources', 'app.asar.unpacked', 'bad-helper.exe'); + writeFile(badBinaryPath, createPortableExecutableBuffer('ia32')); + + await expect(validateNativeBinaries(tempDir, 'win32', 'x64')).resolves.toEqual([ + { + path: path.join('resources', 'app.asar.unpacked', 'bad-helper.exe'), + format: 'pe', + archs: ['ia32'], + }, + ]); + }); }); diff --git a/test/main/build/electronBuilderDistScript.test.ts b/test/main/build/electronBuilderDistScript.test.ts index dcb80d48..79ae4377 100644 --- a/test/main/build/electronBuilderDistScript.test.ts +++ b/test/main/build/electronBuilderDistScript.test.ts @@ -1,14 +1,10 @@ // @vitest-environment node -import { pathToFileURL } from 'node:url'; - import { describe, expect, it } from 'vitest'; -const scriptUrl = pathToFileURL(`${process.cwd()}/scripts/electron-builder/dist.mjs`).href; +const { buildElectronBuilderInvocations } = require('../../../scripts/electron-builder/dist-invocations.cjs'); describe('electron-builder dist wrapper', () => { it('splits multi-platform builds so Linux-only package name overrides do not affect macOS or Windows', async () => { - const { buildElectronBuilderInvocations } = await import(scriptUrl); - expect( buildElectronBuilderInvocations(['--mac', '--win', '--linux', '--publish', 'never']) ).toEqual([ @@ -27,8 +23,6 @@ describe('electron-builder dist wrapper', () => { }); it('adds the filesystem-safe package name override to Linux-only builds', async () => { - const { buildElectronBuilderInvocations } = await import(scriptUrl); - expect(buildElectronBuilderInvocations(['--linux', '--publish', 'never'])).toEqual([ { args: [ @@ -43,8 +37,6 @@ describe('electron-builder dist wrapper', () => { }); it('leaves macOS arch-specific builds unchanged', async () => { - const { buildElectronBuilderInvocations } = await import(scriptUrl); - expect(buildElectronBuilderInvocations(['--mac', '--arm64', '--publish', 'never'])).toEqual([ { args: ['--mac', '--arm64', '--publish', 'never'] }, ]); diff --git a/test/main/services/editor/FileSearchService.test.ts b/test/main/services/editor/FileSearchService.test.ts index f9fa4d45..956580d2 100644 --- a/test/main/services/editor/FileSearchService.test.ts +++ b/test/main/services/editor/FileSearchService.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('fs/promises', () => ({ + access: vi.fn(), readdir: vi.fn(), readFile: vi.fn(), stat: vi.fn(), @@ -36,6 +37,7 @@ describe('FileSearchService', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(fs.access).mockRejectedValue(new Error('not a git repository') as never); service = new FileSearchService(); }); @@ -173,4 +175,27 @@ describe('FileSearchService', () => { expect(result.results[0].matches[1].column).toBe(4); expect(result.results[0].matches[2].column).toBe(8); }); + + it('returns slash-normalized relative paths for quick open on Windows', async () => { + vi.mocked(fs.readdir).mockImplementation(async (dirPath: unknown) => { + const normalized = path.normalize(String(dirPath)); + if (normalized === path.normalize(PROJECT_ROOT)) { + return [{ name: 'src', isFile: () => false, isDirectory: () => true }] as never; + } + if (normalized === path.normalize(path.join(PROJECT_ROOT, 'src'))) { + return [{ name: 'app.ts', isFile: () => true, isDirectory: () => false }] as never; + } + return [] as never; + }); + + const files = await service.listFiles(PROJECT_ROOT); + + expect(files).toEqual([ + { + path: path.join(PROJECT_ROOT, 'src', 'app.ts'), + name: 'app.ts', + relativePath: 'src/app.ts', + }, + ]); + }); }); diff --git a/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts new file mode 100644 index 00000000..bd7e1205 --- /dev/null +++ b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDirectoryTree } from '@renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree'; + +import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection'; + +function injection(path: string): ClaudeMdContextInjection { + return { + id: path, + category: 'claude-md', + path, + source: 'directory', + displayName: 'CLAUDE.md', + isGlobal: false, + estimatedTokens: 12, + firstSeenInGroup: 'ai-0', + }; +} + +describe('buildDirectoryTree Windows paths', () => { + it('strips project root case-insensitively for Windows paths with mixed separators', () => { + const root = buildDirectoryTree( + [injection('c:\\Users\\Alice\\repo\\src\\CLAUDE.md')], + 'C:/Users/Alice/Repo' + ); + + expect(root.children.has('c:')).toBe(false); + expect(root.children.get('src')?.children.get('CLAUDE.md')?.path).toBe( + 'c:\\Users\\Alice\\repo\\src\\CLAUDE.md' + ); + }); + + it('does not strip sibling paths that only share a prefix', () => { + const root = buildDirectoryTree( + [injection('C:\\Users\\Alice\\Repo2\\CLAUDE.md')], + 'C:\\Users\\Alice\\Repo' + ); + + expect(root.children.get('C:')?.children.get('Users')).toBeDefined(); + }); + + it('falls back to the injection path when project root is empty', () => { + const root = buildDirectoryTree([injection('C:\\Users\\Alice\\Repo\\CLAUDE.md')], ''); + + expect(root.children.get('C:')?.children.get('Users')).toBeDefined(); + }); +}); diff --git a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx index 2ed11137..42c8218d 100644 --- a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx +++ b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx @@ -40,7 +40,10 @@ vi.mock('@renderer/components/chat/viewers/FileLink', () => ({ isRelativeUrl: () => false, })); -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { + MarkdownViewer, + resolveRelativePath, +} from '@renderer/components/chat/viewers/MarkdownViewer'; describe('MarkdownViewer code blocks', () => { afterEach(() => { @@ -89,3 +92,23 @@ describe('MarkdownViewer code blocks', () => { }); }); }); + +describe('MarkdownViewer local image path resolution', () => { + it('resolves Windows relative image paths against a Windows base directory', () => { + expect(resolveRelativePath('.\\images\\plot.png', 'C:\\Repo\\docs')).toBe( + 'C:\\Repo\\docs\\images\\plot.png' + ); + }); + + it('preserves absolute Windows image paths', () => { + expect(resolveRelativePath('C:\\Screens\\plot.png', 'C:\\Repo\\docs')).toBe( + 'C:\\Screens\\plot.png' + ); + }); + + it('resolves Windows UNC parent image paths without escaping the share root', () => { + expect(resolveRelativePath('..\\assets\\plot.png', '\\\\server\\share\\repo\\docs')).toBe( + '\\\\server\\share\\repo\\assets\\plot.png' + ); + }); +}); diff --git a/test/renderer/components/fileLink.test.ts b/test/renderer/components/fileLink.test.ts index 851b3bd2..94edc29a 100644 --- a/test/renderer/components/fileLink.test.ts +++ b/test/renderer/components/fileLink.test.ts @@ -121,4 +121,19 @@ describe('resolveFileLinkPath', () => { resolveFileLinkPath('/Users/belief/dev/projects/your_posts/docs/roadmap.md', PROJECT_PATH) ).toBe('/Users/belief/dev/projects/your_posts/docs/roadmap.md'); }); + + it('preserves Windows UNC roots when normalizing path segments', () => { + expect(resolveFileLinkPath('\\\\server\\share\\repo\\..\\docs\\roadmap.md', PROJECT_PATH)).toBe( + '\\\\server\\share\\docs\\roadmap.md' + ); + expect(resolveFileLinkPath('//server/share/repo/../docs/roadmap.md', PROJECT_PATH)).toBe( + '//server/share/docs/roadmap.md' + ); + }); + + it('preserves Windows drive roots when normalizing dot segments', () => { + expect(resolveFileLinkPath('C:\\repo\\src\\..\\README.md', PROJECT_PATH)).toBe( + 'C:\\repo\\README.md' + ); + }); }); diff --git a/test/renderer/hooks/useFileSuggestions.test.ts b/test/renderer/hooks/useFileSuggestions.test.ts index 1f916937..1b9744cf 100644 --- a/test/renderer/hooks/useFileSuggestions.test.ts +++ b/test/renderer/hooks/useFileSuggestions.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { filterFileSuggestions, formatFileMentionPath } from '@renderer/hooks/useFileSuggestions'; +import { + extractDirectories, + filterFileSuggestions, + filterFolderSuggestions, + formatFileMentionPath, +} from '@renderer/hooks/useFileSuggestions'; import type { QuickOpenFile } from '@shared/types/editor'; @@ -91,14 +96,42 @@ describe('filterFileSuggestions', () => { expect(results.map((r) => r.name)).toEqual(['auth.ts', 'database.ts']); }); + it('matches Windows backslash queries against normalized relative paths', () => { + const results = filterFileSuggestions(FILES, 'services\\auth'); + expect(results).toHaveLength(1); + expect(results[0].relativePath).toBe('src/services/auth.ts'); + }); + it('returns results in file list order', () => { const results = filterFileSuggestions(FILES, '.ts'); expect(results[0].name).toBe('index.ts'); }); it('quotes inserted paths that contain spaces', () => { - expect(formatFileMentionPath('src/My Component/App.tsx')).toBe( - '"src/My Component/App.tsx"' - ); + expect(formatFileMentionPath('src/My Component/App.tsx')).toBe('"src/My Component/App.tsx"'); + }); +}); + +describe('folder suggestions', () => { + it('derives stable folders from Windows relative paths', () => { + const folders = extractDirectories( + [ + { + name: 'auth.ts', + relativePath: 'src\\services\\auth.ts', + path: 'C:\\Repo\\src\\services\\auth.ts', + }, + ], + 'C:\\Repo' + ); + + expect(folders.map((f) => [f.name, f.relativePath, f.absolutePath])).toEqual([ + ['src', 'src/', 'C:\\Repo\\src'], + ['services', 'src/services/', 'C:\\Repo\\src\\services'], + ]); + + const suggestions = filterFolderSuggestions(folders, 'src\\services\\'); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].insertText).toBe('src/services/'); }); }); diff --git a/test/renderer/store/pathResolution.test.ts b/test/renderer/store/pathResolution.test.ts index 19de8fad..218390dc 100644 --- a/test/renderer/store/pathResolution.test.ts +++ b/test/renderer/store/pathResolution.test.ts @@ -13,6 +13,7 @@ describe('resolveFilePath', () => { it('resolves dot-prefixed relative paths', () => { expect(resolveFilePath('/repo', './src/app.ts')).toBe('/repo/src/app.ts'); + expect(resolveFilePath('C:\\repo', '.\\src\\app.ts')).toBe('C:\\repo\\src\\app.ts'); }); it('resolves parent relative paths on unix', () => { @@ -27,6 +28,11 @@ describe('resolveFilePath', () => { ); }); + it('preserves Windows drive-root separators when resolving child paths', () => { + expect(resolveFilePath('C:\\', 'src\\app.ts')).toBe('C:\\src\\app.ts'); + expect(resolveFilePath('C:/', 'src/app.ts')).toBe('C:/src/app.ts'); + }); + it('passes through tilde paths as-is', () => { expect(resolveFilePath('/repo', '~/some/directory')).toBe('~/some/directory'); }); @@ -45,6 +51,24 @@ describe('resolveFilePath', () => { ); }); + it('resolves relative paths under Windows UNC roots', () => { + expect(resolveFilePath('\\\\server\\share\\repo', 'src\\index.ts')).toBe( + '\\\\server\\share\\repo\\src\\index.ts' + ); + expect(resolveFilePath('//server/share/repo', 'src/index.ts')).toBe( + '//server/share/repo/src/index.ts' + ); + }); + + it('does not resolve parent paths above a Windows UNC share root', () => { + expect(resolveFilePath('\\\\server\\share', '..\\outside\\file.ts')).toBe( + '\\\\server\\share\\outside\\file.ts' + ); + expect(resolveFilePath('//server/share', '../outside/file.ts')).toBe( + '//server/share/outside/file.ts' + ); + }); + it('does not treat tilde in the middle as special', () => { expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar'); }); diff --git a/test/renderer/utils/claudeMdTracker.test.ts b/test/renderer/utils/claudeMdTracker.test.ts index 6e23567e..32f2199d 100644 --- a/test/renderer/utils/claudeMdTracker.test.ts +++ b/test/renderer/utils/claudeMdTracker.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { detectClaudeMdFromFilePath, + extractUserMentionPaths, getDirectory, getParentDirectory, + processSessionClaudeMd, } from '@renderer/utils/claudeMdTracker'; describe('claudeMdTracker path helpers', () => { @@ -67,6 +69,13 @@ describe('claudeMdTracker path helpers', () => { expect(result).toHaveLength(2); }); + it('detects CLAUDE.md files for Windows paths with drive-case and separator differences', () => { + const result = detectClaudeMdFromFilePath('c:\\Repo\\src\\file.ts', 'C:/repo'); + expect(result).toContain('c:\\Repo\\src\\CLAUDE.md'); + expect(result).toContain('c:\\Repo\\CLAUDE.md'); + expect(result).toHaveLength(2); + }); + it('uses correct separator for generated paths', () => { const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo'); for (const p of unixResult) { @@ -93,3 +102,97 @@ describe('claudeMdTracker path helpers', () => { }); }); }); + +describe('processSessionClaudeMd Windows paths', () => { + function aiReadGroup(id: string, turnIndex: number, filePath: string) { + return { + id, + turnIndex, + startTime: new Date(0), + endTime: new Date(0), + durationMs: 0, + steps: [ + { + type: 'tool_call', + content: { + toolName: 'Read', + toolInput: { file_path: filePath }, + }, + }, + ], + tokens: { input: 1000, output: 0, cached: 0 }, + summary: { + toolCallCount: 1, + outputMessageCount: 0, + subagentCount: 0, + totalDurationMs: 0, + totalTokens: 1000, + outputTokens: 0, + cachedTokens: 0, + }, + status: 'complete', + processes: [], + chunkId: id, + metrics: {}, + responses: [], + } as any; + } + + it('dedupes directory CLAUDE.md paths across Windows case and separator differences', () => { + const stats = processSessionClaudeMd( + [ + { type: 'ai', group: aiReadGroup('ai-0', 0, 'C:\\Repo\\src\\file.ts') }, + { type: 'ai', group: aiReadGroup('ai-1', 1, 'c:/repo/src/other.ts') }, + ], + 'C:\\Repo' + ); + + const firstDirectories = stats + .get('ai-0')! + .newInjections.filter((injection) => injection.source === 'directory') + .map((injection) => injection.path); + const secondDirectories = stats + .get('ai-1')! + .newInjections.filter((injection) => injection.source === 'directory'); + + expect(firstDirectories).toEqual(['C:\\Repo\\src\\CLAUDE.md']); + expect(secondDirectories).toEqual([]); + }); +}); + +describe('extractUserMentionPaths Windows paths', () => { + function userGroupWithPath(path: string) { + return { + content: { + fileReferences: [{ path, raw: `@${path}` }], + }, + } as any; + } + + it('resolves Windows current-directory mentions with backslash separators', () => { + expect(extractUserMentionPaths(userGroupWithPath('.\\src\\app.ts'), 'C:\\Repo')).toEqual([ + 'C:\\Repo\\src\\app.ts', + ]); + }); + + it('preserves Windows drive-root separators for relative mentions', () => { + expect(extractUserMentionPaths(userGroupWithPath('src\\app.ts'), 'C:\\')).toEqual([ + 'C:\\src\\app.ts', + ]); + }); + + it('resolves relative mentions under UNC roots without escaping the share root', () => { + expect( + extractUserMentionPaths(userGroupWithPath('../outside/file.ts'), '//server/share') + ).toEqual(['//server/share/outside/file.ts']); + expect( + extractUserMentionPaths(userGroupWithPath('..\\outside\\file.ts'), '\\\\server\\share') + ).toEqual(['\\\\server\\share\\outside\\file.ts']); + }); + + it('leaves home-relative mentions untouched', () => { + expect(extractUserMentionPaths(userGroupWithPath('~\\.claude\\CLAUDE.md'), 'C:\\Repo')).toEqual( + ['~\\.claude\\CLAUDE.md'] + ); + }); +}); diff --git a/test/renderer/utils/contextTracker.test.ts b/test/renderer/utils/contextTracker.test.ts new file mode 100644 index 00000000..ae2a04c6 --- /dev/null +++ b/test/renderer/utils/contextTracker.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { processSessionContextWithPhases } from '@renderer/utils/contextTracker'; + +function aiReadGroup(id: string, turnIndex: number, filePath: string) { + return { + id, + turnIndex, + startTime: new Date(0), + endTime: new Date(0), + durationMs: 0, + steps: [ + { + type: 'tool_call', + content: { + toolName: 'Read', + toolInput: { file_path: filePath }, + }, + }, + ], + tokens: { input: 1000, output: 0, cached: 0 }, + summary: { + toolCallCount: 1, + outputMessageCount: 0, + subagentCount: 0, + totalDurationMs: 0, + totalTokens: 1000, + outputTokens: 0, + cachedTokens: 0, + }, + status: 'complete', + processes: [], + chunkId: id, + metrics: {}, + responses: [], + linkedTools: new Map(), + displayItems: [], + } as any; +} + +describe('processSessionContextWithPhases Windows paths', () => { + it('matches validated directory CLAUDE.md data across drive-case and separator differences', () => { + const { statsMap } = processSessionContextWithPhases( + [{ type: 'ai', group: aiReadGroup('ai-0', 0, 'c:/repo/src/file.ts') }], + 'C:\\Repo', + undefined, + undefined, + { + 'C:\\Repo\\src\\CLAUDE.md': { + path: 'C:\\Repo\\src\\CLAUDE.md', + exists: true, + charCount: 492, + estimatedTokens: 123, + }, + } + ); + + const directoryInjection = statsMap + .get('ai-0')! + .newInjections.find( + (injection) => injection.category === 'claude-md' && injection.source === 'directory' + ); + + expect(directoryInjection?.estimatedTokens).toBe(123); + }); +}); diff --git a/test/renderer/utils/pathDisplay.test.ts b/test/renderer/utils/pathDisplay.test.ts index 20c5976b..f476bf16 100644 --- a/test/renderer/utils/pathDisplay.test.ts +++ b/test/renderer/utils/pathDisplay.test.ts @@ -8,25 +8,44 @@ import { describe('pathDisplay Windows paths', () => { it('treats lowercase drive paths as absolute', () => { - expect(resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')).toBe( - 'c:\\Users\\Alice\\repo\\src\\app.ts' - ); + expect( + resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo') + ).toBe('c:\\Users\\Alice\\repo\\src\\app.ts'); }); it('shortens project-root relative paths case-insensitively on Windows', () => { - expect(shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')).toBe( + expect( + shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo') + ).toBe('src\\app.ts'); + }); + + it('shortens mixed-separator Windows paths without treating siblings as children', () => { + expect(shortenDisplayPath('c:\\Users\\Alice\\Repo\\src\\app.ts', 'C:/Users/Alice/repo')).toBe( 'src\\app.ts' ); + expect( + shortenDisplayPath('C:\\Users\\Alice\\Repo2\\src\\app.ts', 'C:\\Users\\Alice\\Repo') + ).toBe('~\\Repo2\\src\\app.ts'); }); it('formats lowercase Windows user paths with a home marker', () => { expect(formatProjectPath('c:\\users\\Alice\\repo')).toBe('~/repo'); + expect(formatProjectPath('C:/Users/Alice/repo')).toBe('~/repo'); }); it('resolves home paths from lowercase Windows user roots', () => { expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe( 'c:\\users\\Alice\\repo\\src\\app.ts' ); + expect(resolveAbsolutePath('~/repo/src/app.ts', 'C:/Users/Alice/workspace')).toBe( + 'C:/Users/Alice/repo/src/app.ts' + ); + }); + + it('shortens forward-slash Windows user paths with a home marker', () => { + expect(shortenDisplayPath('C:/Users/Alice/repo/src/app.ts', undefined, 80)).toBe( + '~/repo/src/app.ts' + ); }); it('resolves relative paths using the project root separator', () => { diff --git a/test/shared/utils/platformPath.test.ts b/test/shared/utils/platformPath.test.ts new file mode 100644 index 00000000..19d168cc --- /dev/null +++ b/test/shared/utils/platformPath.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { getRelativePathWithinPrefix, isPathPrefix } from '../../../src/shared/utils/platformPath'; + +describe('platformPath Windows containment', () => { + it('matches Windows drive paths case-insensitively and preserves child path style', () => { + expect(isPathPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')).toBe(true); + expect( + getRelativePathWithinPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts') + ).toBe('src\\app.ts'); + }); + + it('matches UNC paths with mixed separators', () => { + expect( + getRelativePathWithinPrefix('\\\\server\\share\\Repo', '//server/share/repo/src/app.ts') + ).toBe('src/app.ts'); + }); + + it('rejects sibling paths that only share the same text prefix', () => { + expect( + getRelativePathWithinPrefix('C:\\Users\\Alice\\Repo', 'C:\\Users\\Alice\\Repo2\\x.ts') + ).toBe(null); + }); + + it('keeps POSIX paths case-sensitive', () => { + expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/Repo/src/app.ts')).toBe( + 'src/app.ts' + ); + expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/repo/src/app.ts')).toBe( + null + ); + }); + + it('does not treat an empty prefix as the root of absolute paths', () => { + expect(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false); + expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null); + }); +});