fix: harden Windows frontend path handling
Harden Windows path handling and packaged app smoke checks.
This commit is contained in:
parent
2beb4dae96
commit
09db3abfcd
33 changed files with 1088 additions and 163 deletions
|
|
@ -159,6 +159,93 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
|
||||||
return removedPaths;
|
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) {
|
function mapMachOCpuType(cpuType) {
|
||||||
switch (cpuType >>> 0) {
|
switch (cpuType >>> 0) {
|
||||||
case 0x00000007:
|
case 0x00000007:
|
||||||
|
|
@ -335,6 +422,7 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
|
||||||
const files = await walkFiles(appOutDir);
|
const files = await walkFiles(appOutDir);
|
||||||
|
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
|
const relativePath = path.relative(appOutDir, filePath);
|
||||||
const metadata = await detectBinaryMetadata(filePath);
|
const metadata = await detectBinaryMetadata(filePath);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -344,8 +432,14 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
mismatches.push({
|
mismatches.push({
|
||||||
path: path.relative(appOutDir, filePath),
|
path: relativePath,
|
||||||
format: metadata.format,
|
format: metadata.format,
|
||||||
archs: [...metadata.archs].sort(),
|
archs: [...metadata.archs].sort(),
|
||||||
});
|
});
|
||||||
|
|
@ -358,7 +452,14 @@ async function afterPack(context) {
|
||||||
const targetPlatform = context.electronPlatformName;
|
const targetPlatform = context.electronPlatformName;
|
||||||
const targetArch = getArchLabel(context.arch);
|
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);
|
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);
|
||||||
|
|
||||||
if (mismatches.length > 0) {
|
if (mismatches.length > 0) {
|
||||||
|
|
@ -383,9 +484,11 @@ module.exports._internal = {
|
||||||
detectBinaryMetadata,
|
detectBinaryMetadata,
|
||||||
getArchLabel,
|
getArchLabel,
|
||||||
isBinaryCompatible,
|
isBinaryCompatible,
|
||||||
|
isKnownAllowedNativeMismatch,
|
||||||
parseElf,
|
parseElf,
|
||||||
parseMachO,
|
parseMachO,
|
||||||
parsePortableExecutable,
|
parsePortableExecutable,
|
||||||
|
pruneKnownIncompatibleNativeArtifacts,
|
||||||
pruneNodePtyArtifacts,
|
pruneNodePtyArtifacts,
|
||||||
validateNativeBinaries,
|
validateNativeBinaries,
|
||||||
walkFiles,
|
walkFiles,
|
||||||
|
|
|
||||||
51
scripts/electron-builder/dist-invocations.cjs
Normal file
51
scripts/electron-builder/dist-invocations.cjs
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
52
scripts/electron-builder/dist.mjs
Normal file
52
scripts/electron-builder/dist.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
|
||||||
|
|
||||||
|
export { buildElectronBuilderInvocations };
|
||||||
|
|
||||||
|
async function runElectronBuilder(args) {
|
||||||
|
const cliPath = require.resolve('electron-builder/cli.js');
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, [cliPath, ...args], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`electron-builder failed with ${signal ?? `exit code ${code}`}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(argv) {
|
||||||
|
const invocations = buildElectronBuilderInvocations(argv);
|
||||||
|
|
||||||
|
if (process.env.ELECTRON_BUILDER_DIST_DRY_RUN === '1') {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
invocations.map((invocation) => invocation.args),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const invocation of invocations) {
|
||||||
|
await runElectronBuilder(invocation.args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPointUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
|
||||||
|
if (entryPointUrl === import.meta.url) {
|
||||||
|
await main(process.argv.slice(2));
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
|
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 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 REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
|
||||||
const FAILURE_PATTERNS = [
|
const FAILURE_PATTERNS = [
|
||||||
/Cannot find module/i,
|
/Cannot find module/i,
|
||||||
|
|
@ -39,7 +40,10 @@ function findExecutable(bundlePath, platform) {
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
const executable = fs
|
const executable = fs
|
||||||
.readdirSync(bundlePath)
|
.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}`);
|
if (!executable) fail(`No .exe found in ${bundlePath}`);
|
||||||
return path.join(bundlePath, executable);
|
return path.join(bundlePath, executable);
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +70,45 @@ function findExecutable(bundlePath, platform) {
|
||||||
fail(`Unsupported platform: ${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() {
|
async function main() {
|
||||||
const [bundlePathArg, platform] = process.argv.slice(2);
|
const [bundlePathArg, platform] = process.argv.slice(2);
|
||||||
if (!bundlePathArg || !platform) {
|
if (!bundlePathArg || !platform) {
|
||||||
|
|
@ -100,7 +143,7 @@ async function main() {
|
||||||
let startupSeenAt = null;
|
let startupSeenAt = null;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
|
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
|
||||||
child.kill();
|
await terminateChild(child, exitPromise, platform);
|
||||||
fail('Detected startup failure pattern', log);
|
fail('Detected startup failure pattern', log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +152,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
|
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
|
||||||
child.kill();
|
await terminateChild(child, exitPromise, platform);
|
||||||
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
|
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -119,11 +162,14 @@ async function main() {
|
||||||
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
|
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
|
||||||
]);
|
]);
|
||||||
if (exit) {
|
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);
|
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,18 @@ const afterPackModule = require('./afterPack.cjs');
|
||||||
|
|
||||||
const { validateNativeBinaries } = afterPackModule._internal;
|
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() {
|
async function main() {
|
||||||
const [bundlePathArg, platform, arch] = process.argv.slice(2);
|
const [bundlePathArg, platform, arch] = process.argv.slice(2);
|
||||||
|
|
||||||
|
|
@ -14,16 +26,22 @@ async function main() {
|
||||||
|
|
||||||
const bundlePath = path.resolve(bundlePathArg);
|
const bundlePath = path.resolve(bundlePathArg);
|
||||||
const mismatches = await validateNativeBinaries(bundlePath, platform, arch);
|
const mismatches = await validateNativeBinaries(bundlePath, platform, arch);
|
||||||
|
const blockingMismatches = mismatches.filter(
|
||||||
|
(mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch)
|
||||||
|
);
|
||||||
|
|
||||||
if (mismatches.length === 0) {
|
if (blockingMismatches.length === 0) {
|
||||||
console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(
|
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(', ')}`);
|
console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`);
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env node
|
#!/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 { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
@ -326,12 +327,93 @@ function parseArgs(argv) {
|
||||||
return { all, jsonPath, list, selected };
|
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) {
|
function runCommand(command) {
|
||||||
return new Promise((resolve) => {
|
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'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: process.env,
|
env: process.env,
|
||||||
cwd: command.cwd,
|
cwd: command.cwd,
|
||||||
|
...spawnInvocation.options,
|
||||||
});
|
});
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||||
|
import { normalizePathForComparison } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ function normalizeHistoryPath(projectPath: string): string | null {
|
||||||
normalizedPath = normalizedPath.slice(0, -1);
|
normalizedPath = normalizedPath.slice(0, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return normalizedPath;
|
return normalizedPath ? normalizePathForComparison(normalizedPath) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function foldHistoryPath(projectPath: string): string {
|
function foldHistoryPath(projectPath: string): string {
|
||||||
|
|
|
||||||
|
|
@ -292,9 +292,7 @@ export class FileSearchService {
|
||||||
subdirs.push(fullPath);
|
subdirs.push(fullPath);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
if (IGNORED_FILES.has(entry.name)) continue;
|
if (IGNORED_FILES.has(entry.name)) continue;
|
||||||
const relativePath = fullPath.startsWith(projectRoot)
|
const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join('/');
|
||||||
? fullPath.slice(projectRoot.length + 1)
|
|
||||||
: entry.name;
|
|
||||||
files.push({ path: fullPath, name: entry.name, relativePath });
|
files.push({ path: fullPath, name: entry.name, relativePath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Build a directory tree structure from CLAUDE.md injections.
|
* Build a directory tree structure from CLAUDE.md injections.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getRelativePathWithinPrefix } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { TreeNode } from './types';
|
import type { TreeNode } from './types';
|
||||||
import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
|
import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
|
|
@ -15,12 +17,9 @@ export function buildDirectoryTree(
|
||||||
const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() };
|
const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() };
|
||||||
|
|
||||||
for (const injection of injections) {
|
for (const injection of injections) {
|
||||||
let relativePath = injection.path;
|
const relativePath = projectRoot
|
||||||
if (projectRoot && relativePath.startsWith(projectRoot)) {
|
? (getRelativePathWithinPrefix(projectRoot, injection.path) ?? injection.path)
|
||||||
relativePath = relativePath.slice(projectRoot.length);
|
: injection.path;
|
||||||
if (relativePath.startsWith('/') || relativePath.startsWith('\\'))
|
|
||||||
relativePath = relativePath.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = relativePath.split(/[\\/]/);
|
const parts = relativePath.split(/[\\/]/);
|
||||||
let current = root;
|
let current = root;
|
||||||
|
|
|
||||||
|
|
@ -57,21 +57,20 @@ export function resolveFileLinkPath(filePath: string, projectPath: string): stri
|
||||||
function normalizePathSegments(filePath: string): string {
|
function normalizePathSegments(filePath: string): string {
|
||||||
const hasBackslash = filePath.includes('\\') && !filePath.includes('/');
|
const hasBackslash = filePath.includes('\\') && !filePath.includes('/');
|
||||||
const separator = hasBackslash ? '\\' : '/';
|
const separator = hasBackslash ? '\\' : '/';
|
||||||
const normalized = filePath.replace(/[/\\]+/g, separator);
|
|
||||||
|
|
||||||
let prefix = '';
|
let prefix = '';
|
||||||
let body = normalized;
|
let body = filePath;
|
||||||
|
|
||||||
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(normalized);
|
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(filePath);
|
||||||
if (driveMatch) {
|
if (driveMatch) {
|
||||||
prefix = `${driveMatch[1]}${separator}`;
|
prefix = `${driveMatch[1]}${separator}`;
|
||||||
body = normalized.slice(prefix.length);
|
body = filePath.slice(driveMatch[0].length);
|
||||||
} else if (normalized.startsWith(`${separator}${separator}`)) {
|
} else if (filePath.startsWith('\\\\') || filePath.startsWith('//')) {
|
||||||
prefix = `${separator}${separator}`;
|
prefix = `${separator}${separator}`;
|
||||||
body = normalized.slice(2);
|
body = filePath.slice(2);
|
||||||
} else if (normalized.startsWith(separator)) {
|
} else if (filePath.startsWith('/') || filePath.startsWith('\\')) {
|
||||||
prefix = separator;
|
prefix = separator;
|
||||||
body = normalized.slice(1);
|
body = filePath.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { resolveFilePath } from '@renderer/store/utils/pathResolution';
|
||||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
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 */
|
/** Resolve a relative path to an absolute path given a base directory */
|
||||||
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
export function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
||||||
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
|
return resolveFilePath(baseDir, relativeSrc);
|
||||||
return `${baseDir}/${cleaned}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,9 @@ export const GeneralSection = ({
|
||||||
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
||||||
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
|
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
|
||||||
const isWindowsStyleDefaultPath =
|
const isWindowsStyleDefaultPath =
|
||||||
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
/^[a-zA-Z]:[/\\]/.test(defaultClaudeRootPath) ||
|
||||||
|
defaultClaudeRootPath.startsWith('\\\\') ||
|
||||||
|
defaultClaudeRootPath.startsWith('//');
|
||||||
|
|
||||||
const isElectron = useMemo(() => isElectronMode(), []);
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||||
import { lastSeparatorIndex } from '@shared/utils/platformPath';
|
import { getRelativePathWithinPrefix, lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||||
import {
|
import {
|
||||||
ClipboardCopy,
|
ClipboardCopy,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
|
|
@ -87,6 +87,8 @@ export const EditorContextMenu = ({
|
||||||
? target.path
|
? target.path
|
||||||
: target.path.substring(0, lastSeparatorIndex(target.path))
|
: target.path.substring(0, lastSeparatorIndex(target.path))
|
||||||
: null;
|
: null;
|
||||||
|
const targetRelativePath =
|
||||||
|
projectPath && target ? getRelativePathWithinPrefix(projectPath, target.path) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
|
|
@ -154,12 +156,11 @@ export const EditorContextMenu = ({
|
||||||
Copy Path
|
Copy Path
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
|
|
||||||
{projectPath && target.path.startsWith(projectPath) && (
|
{targetRelativePath !== null && (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
const relative = target.path.slice(projectPath.length + 1);
|
void navigator.clipboard.writeText(targetRelativePath);
|
||||||
void navigator.clipboard.writeText(relative);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ClipboardCopy className="size-3.5 text-text-muted" />
|
<ClipboardCopy className="size-3.5 text-text-muted" />
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
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 { Loader2, Search, X } from 'lucide-react';
|
||||||
|
|
||||||
import { FileIcon } from './FileIcon';
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
@ -146,7 +150,7 @@ export const SearchInFilesPanel = ({
|
||||||
|
|
||||||
const getRelativePath = useCallback(
|
const getRelativePath = useCallback(
|
||||||
(filePath: string) => {
|
(filePath: string) => {
|
||||||
return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath;
|
return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath;
|
||||||
},
|
},
|
||||||
[projectPath]
|
[projectPath]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
onQuickOpenCacheInvalidated,
|
onQuickOpenCacheInvalidated,
|
||||||
setQuickOpenCache,
|
setQuickOpenCache,
|
||||||
} from '@renderer/utils/quickOpenCache';
|
} from '@renderer/utils/quickOpenCache';
|
||||||
|
import { joinPath, splitPath } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { QuickOpenFile } from '@shared/types/editor';
|
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.
|
* Extracts unique directories from a list of file paths.
|
||||||
* Returns directories sorted by depth (shallower first), then alphabetically.
|
* 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<string>();
|
const dirSet = new Set<string>();
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// Walk up the directory chain from each file's relative path
|
// 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
|
// Remove the file name — keep only directory segments
|
||||||
for (let i = 1; i < parts.length; i++) {
|
for (let i = 1; i < parts.length; i++) {
|
||||||
dirSet.add(parts.slice(0, i).join('/'));
|
dirSet.add(parts.slice(0, i).join('/'));
|
||||||
|
|
@ -67,19 +68,19 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
|
||||||
|
|
||||||
const folders: DerivedFolder[] = [];
|
const folders: DerivedFolder[] = [];
|
||||||
for (const relDir of dirSet) {
|
for (const relDir of dirSet) {
|
||||||
const segments = relDir.split('/');
|
const segments = splitPath(relDir);
|
||||||
const name = segments[segments.length - 1];
|
const name = segments[segments.length - 1];
|
||||||
folders.push({
|
folders.push({
|
||||||
name,
|
name,
|
||||||
relativePath: relDir + '/',
|
relativePath: relDir + '/',
|
||||||
absolutePath: projectPath + '/' + relDir,
|
absolutePath: joinPath(projectPath, ...splitPath(relDir)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: shallower first, then alphabetically
|
// Sort: shallower first, then alphabetically
|
||||||
folders.sort((a, b) => {
|
folders.sort((a, b) => {
|
||||||
const depthA = a.relativePath.split('/').length;
|
const depthA = splitPath(a.relativePath).length;
|
||||||
const depthB = b.relativePath.split('/').length;
|
const depthB = splitPath(b.relativePath).length;
|
||||||
if (depthA !== depthB) return depthA - depthB;
|
if (depthA !== depthB) return depthA - depthB;
|
||||||
return a.relativePath.localeCompare(b.relativePath);
|
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[] {
|
export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] {
|
||||||
if (!query || files.length === 0) return [];
|
if (!query || files.length === 0) return [];
|
||||||
|
|
||||||
const lower = query.toLowerCase();
|
const lower = query.replace(/\\/g, '/').toLowerCase();
|
||||||
const results: MentionSuggestion[] = [];
|
const results: MentionSuggestion[] = [];
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (results.length >= MAX_FILE_SUGGESTIONS) break;
|
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({
|
results.push({
|
||||||
id: `file:${f.path}`,
|
id: `file:${f.path}`,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
|
|
@ -127,14 +129,15 @@ export function filterFolderSuggestions(
|
||||||
if (!query || folders.length === 0) return [];
|
if (!query || folders.length === 0) return [];
|
||||||
|
|
||||||
// Strip trailing slash from query for matching (e.g. "ui/" -> "ui")
|
// Strip trailing slash from query for matching (e.g. "ui/" -> "ui")
|
||||||
const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query;
|
const cleanQuery = query.endsWith('/') || query.endsWith('\\') ? query.slice(0, -1) : query;
|
||||||
const lower = cleanQuery.toLowerCase();
|
const lower = cleanQuery.replace(/\\/g, '/').toLowerCase();
|
||||||
const results: MentionSuggestion[] = [];
|
const results: MentionSuggestion[] = [];
|
||||||
|
|
||||||
for (const f of folders) {
|
for (const f of folders) {
|
||||||
if (results.length >= MAX_FOLDER_SUGGESTIONS) break;
|
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({
|
results.push({
|
||||||
id: `folder:${f.absolutePath}`,
|
id: `folder:${f.absolutePath}`,
|
||||||
name: f.name + '/',
|
name: f.name + '/',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Path resolution utilities for the store.
|
* Path resolution utilities for the store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stripTrailingSeparators } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a relative path against a base path, handling various path formats.
|
* Resolves a relative path against a base path, handling various path formats.
|
||||||
* Handles:
|
* Handles:
|
||||||
|
|
@ -17,7 +19,7 @@ export function resolveFilePath(base: string, relativePath: string): string {
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanBase = trimTrailingSeparator(base);
|
const cleanBase = stripTrailingSeparators(base);
|
||||||
|
|
||||||
// Handle @ prefix (file mention marker) - strip it if present
|
// Handle @ prefix (file mention marker) - strip it if present
|
||||||
let cleanRelative = relativePath;
|
let cleanRelative = relativePath;
|
||||||
|
|
@ -32,21 +34,22 @@ export function resolveFilePath(base: string, relativePath: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ./ prefix (current directory)
|
// Handle ./ prefix (current directory)
|
||||||
if (cleanRelative.startsWith('./')) {
|
if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
|
||||||
cleanRelative = cleanRelative.slice(2);
|
cleanRelative = cleanRelative.slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ../ prefixes (parent directory)
|
// Handle ../ prefixes (parent directory)
|
||||||
const separator = cleanBase.includes('\\') ? '\\' : '/';
|
const separator = cleanBase.includes('\\') ? '\\' : '/';
|
||||||
const hasUnixRoot = cleanBase.startsWith('/');
|
const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
|
||||||
const hasUncRoot = cleanBase.startsWith('\\\\');
|
const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
|
||||||
|
const minRootParts = hasUncRoot ? 2 : 1;
|
||||||
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
|
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
|
||||||
const baseParts = splitPath(cleanBase);
|
const baseParts = splitPath(cleanBase);
|
||||||
let remainingRelative = normalizedRelative;
|
let remainingRelative = normalizedRelative;
|
||||||
|
|
||||||
while (remainingRelative.startsWith(`..${separator}`)) {
|
while (remainingRelative.startsWith(`..${separator}`)) {
|
||||||
remainingRelative = remainingRelative.slice(3);
|
remainingRelative = remainingRelative.slice(3);
|
||||||
if (baseParts.length > 1) {
|
if (baseParts.length > minRootParts) {
|
||||||
baseParts.pop();
|
baseParts.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,8 +59,8 @@ export function resolveFilePath(base: string, relativePath: string): string {
|
||||||
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
|
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
|
||||||
normalizedBase = `/${normalizedBase}`;
|
normalizedBase = `/${normalizedBase}`;
|
||||||
}
|
}
|
||||||
if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
|
if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
|
||||||
normalizedBase = `\\\\${normalizedBase}`;
|
normalizedBase = `${separator}${separator}${normalizedBase}`;
|
||||||
}
|
}
|
||||||
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : 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);
|
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 {
|
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
|
||||||
let output = '';
|
let output = '';
|
||||||
let prevWasSeparator = false;
|
let prevWasSeparator = false;
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isPathPrefix,
|
||||||
lastSeparatorIndex,
|
lastSeparatorIndex,
|
||||||
|
normalizePathForComparison,
|
||||||
splitPath as splitPathCrossPlatform,
|
splitPath as splitPathCrossPlatform,
|
||||||
|
stripTrailingSeparators,
|
||||||
} from '@shared/utils/platformPath';
|
} from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import { extractFileReferences } from './groupTransformer';
|
import { extractFileReferences } from './groupTransformer';
|
||||||
|
|
@ -64,7 +67,14 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string {
|
||||||
* Check if a path is absolute (starts with /).
|
* Check if a path is absolute (starts with /).
|
||||||
*/
|
*/
|
||||||
function isAbsolutePath(path: string): boolean {
|
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
|
// Remove trailing slash from base if present
|
||||||
const cleanBase = trimTrailingSeparator(base);
|
const cleanBase = stripTrailingSeparators(base);
|
||||||
|
|
||||||
// Handle @ prefix (file mention marker) - strip it if present
|
// Handle @ prefix (file mention marker) - strip it if present
|
||||||
let cleanRelative = relative;
|
let cleanRelative = relative;
|
||||||
|
|
@ -91,20 +101,21 @@ function joinPaths(base: string, relative: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ./ prefix (current directory)
|
// Handle ./ prefix (current directory)
|
||||||
if (cleanRelative.startsWith('./')) {
|
if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
|
||||||
cleanRelative = cleanRelative.slice(2);
|
cleanRelative = cleanRelative.slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ../ prefixes (parent directory)
|
// Handle ../ prefixes (parent directory)
|
||||||
const separator = cleanBase.includes('\\') ? '\\' : '/';
|
const separator = cleanBase.includes('\\') ? '\\' : '/';
|
||||||
const hasUnixRoot = cleanBase.startsWith('/');
|
const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
|
||||||
const hasUncRoot = cleanBase.startsWith('\\\\');
|
const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
|
||||||
|
const minRootParts = hasUncRoot ? 2 : 1;
|
||||||
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
|
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
|
||||||
const baseParts = splitPath(cleanBase);
|
const baseParts = splitPath(cleanBase);
|
||||||
let remainingRelative = normalizedRelative;
|
let remainingRelative = normalizedRelative;
|
||||||
while (remainingRelative.startsWith(`..${separator}`)) {
|
while (remainingRelative.startsWith(`..${separator}`)) {
|
||||||
remainingRelative = remainingRelative.slice(3);
|
remainingRelative = remainingRelative.slice(3);
|
||||||
if (baseParts.length > 1) {
|
if (baseParts.length > minRootParts) {
|
||||||
baseParts.pop();
|
baseParts.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,24 +125,12 @@ function joinPaths(base: string, relative: string): string {
|
||||||
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
|
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
|
||||||
normalizedBase = `/${normalizedBase}`;
|
normalizedBase = `/${normalizedBase}`;
|
||||||
}
|
}
|
||||||
if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
|
if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
|
||||||
normalizedBase = `\\\\${normalizedBase}`;
|
normalizedBase = `${separator}${separator}${normalizedBase}`;
|
||||||
}
|
}
|
||||||
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : 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 {
|
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
|
||||||
let output = '';
|
let output = '';
|
||||||
let prevWasSeparator = false;
|
let prevWasSeparator = false;
|
||||||
|
|
@ -158,7 +157,19 @@ function splitPath(input: string): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForComparison(input: string): string {
|
function normalizeForComparison(input: string): string {
|
||||||
return input.replace(/\\/g, '/');
|
return normalizePathForComparison(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeenPathSet(paths: string[]): Set<string> {
|
||||||
|
return new Set(paths.map(normalizeForComparison));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSeenPath(seenPaths: Set<string>, path: string): boolean {
|
||||||
|
return seenPaths.has(normalizeForComparison(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberPath(seenPaths: Set<string>, 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.
|
* Check if dirPath is at or above stopPath in the directory tree.
|
||||||
*/
|
*/
|
||||||
function isAtOrAbove(dirPath: string, stopPath: string): boolean {
|
function isAtOrAbove(dirPath: string, stopPath: string): boolean {
|
||||||
const normDir = normalizeForComparison(dirPath).replace(/\/$/, '');
|
return isPathPrefix(dirPath, stopPath);
|
||||||
const normStop = normalizeForComparison(stopPath).replace(/\/$/, '');
|
|
||||||
|
|
||||||
// dirPath is at or above stopPath if stopPath starts with dirPath
|
|
||||||
return normStop === normDir || normStop.startsWith(normDir + '/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -485,7 +492,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const newInjections: ClaudeMdInjection[] = [];
|
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
|
// For the first group, add global injections
|
||||||
// Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel
|
// Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel
|
||||||
|
|
@ -493,9 +500,9 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
|
||||||
if (isFirstGroup) {
|
if (isFirstGroup) {
|
||||||
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData);
|
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData);
|
||||||
for (const injection of globalInjections) {
|
for (const injection of globalInjections) {
|
||||||
if (!previousPaths.has(injection.path)) {
|
if (!hasSeenPath(previousPaths, injection.path)) {
|
||||||
newInjections.push(injection);
|
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) {
|
for (const claudeMdPath of claudeMdPaths) {
|
||||||
// Skip if already seen
|
// Skip if already seen
|
||||||
if (previousPaths.has(claudeMdPath)) {
|
if (hasSeenPath(previousPaths, claudeMdPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -546,7 +553,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
|
||||||
// Create directory injection
|
// Create directory injection
|
||||||
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
|
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
|
||||||
newInjections.push(injection);
|
newInjections.push(injection);
|
||||||
previousPaths.add(claudeMdPath);
|
rememberPath(previousPaths, claudeMdPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
* This builds on claudeMdTracker.ts and extends it to track all context sources.
|
* 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 { estimateTokens } from '@shared/utils/tokenFormatting';
|
||||||
|
|
||||||
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
|
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
|
||||||
|
|
@ -474,7 +475,7 @@ function joinPaths(base: string, relative: string): string {
|
||||||
return relative;
|
return relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanBase = trimTrailingSeparator(base);
|
const cleanBase = stripTrailingSeparators(base);
|
||||||
|
|
||||||
// Handle @ prefix (file mention marker) - strip it if present
|
// Handle @ prefix (file mention marker) - strip it if present
|
||||||
let cleanRelative = relative;
|
let cleanRelative = relative;
|
||||||
|
|
@ -483,20 +484,21 @@ function joinPaths(base: string, relative: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ./ prefix (current directory)
|
// Handle ./ prefix (current directory)
|
||||||
if (cleanRelative.startsWith('./')) {
|
if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
|
||||||
cleanRelative = cleanRelative.slice(2);
|
cleanRelative = cleanRelative.slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ../ prefixes (parent directory)
|
// Handle ../ prefixes (parent directory)
|
||||||
const separator = cleanBase.includes('\\') ? '\\' : '/';
|
const separator = cleanBase.includes('\\') ? '\\' : '/';
|
||||||
const hasUnixRoot = cleanBase.startsWith('/');
|
const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
|
||||||
const hasUncRoot = cleanBase.startsWith('\\\\');
|
const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
|
||||||
|
const minRootParts = hasUncRoot ? 2 : 1;
|
||||||
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
|
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
|
||||||
const baseParts = splitPath(cleanBase);
|
const baseParts = splitPath(cleanBase);
|
||||||
let remainingRelative = normalizedRelative;
|
let remainingRelative = normalizedRelative;
|
||||||
while (remainingRelative.startsWith(`..${separator}`)) {
|
while (remainingRelative.startsWith(`..${separator}`)) {
|
||||||
remainingRelative = remainingRelative.slice(3);
|
remainingRelative = remainingRelative.slice(3);
|
||||||
if (baseParts.length > 1) {
|
if (baseParts.length > minRootParts) {
|
||||||
baseParts.pop();
|
baseParts.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -506,24 +508,12 @@ function joinPaths(base: string, relative: string): string {
|
||||||
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
|
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
|
||||||
normalizedBase = `/${normalizedBase}`;
|
normalizedBase = `/${normalizedBase}`;
|
||||||
}
|
}
|
||||||
if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
|
if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
|
||||||
normalizedBase = `\\\\${normalizedBase}`;
|
normalizedBase = `${separator}${separator}${normalizedBase}`;
|
||||||
}
|
}
|
||||||
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : 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 {
|
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
|
||||||
let output = '';
|
let output = '';
|
||||||
let prevWasSeparator = false;
|
let prevWasSeparator = false;
|
||||||
|
|
@ -567,7 +557,50 @@ function splitPath(input: string): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForComparison(input: string): string {
|
function normalizeForComparison(input: string): string {
|
||||||
return input.replace(/\\/g, '/');
|
return normalizePathForComparison(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeenPathSet(paths: string[]): Set<string> {
|
||||||
|
return new Set(paths.map(normalizeForComparison));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSeenPath(seenPaths: Set<string>, path: string): boolean {
|
||||||
|
return seenPaths.has(normalizeForComparison(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberPath(seenPaths: Set<string>, path: string): void {
|
||||||
|
seenPaths.add(normalizeForComparison(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordValueByPath<T>(
|
||||||
|
record: Record<string, T> | 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<T>(map: Map<string, T> | 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;
|
} = params;
|
||||||
|
|
||||||
const newInjections: ContextInjection[] = [];
|
const newInjections: ContextInjection[] = [];
|
||||||
const previousPaths = new Set(
|
const previousPaths = createSeenPathSet(
|
||||||
previousInjections
|
previousInjections
|
||||||
.filter(
|
.filter(
|
||||||
(inj): inj is ClaudeMdContextInjection | MentionedFileInjection =>
|
(inj): inj is ClaudeMdContextInjection | MentionedFileInjection =>
|
||||||
|
|
@ -620,9 +653,9 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
if (isFirstGroup) {
|
if (isFirstGroup) {
|
||||||
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData);
|
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData);
|
||||||
for (const injection of globalInjections) {
|
for (const injection of globalInjections) {
|
||||||
if (!previousPaths.has(injection.path)) {
|
if (!hasSeenPath(previousPaths, injection.path)) {
|
||||||
newInjections.push(wrapClaudeMdInjection(injection));
|
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) {
|
for (const claudeMdPath of claudeMdPaths) {
|
||||||
// Skip if already seen
|
// Skip if already seen
|
||||||
if (previousPaths.has(claudeMdPath)) {
|
if (hasSeenPath(previousPaths, claudeMdPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,7 +707,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
// Only include directory CLAUDE.md files that exist (validated via directoryTokenData)
|
// 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 is provided and doesn't contain this path, the file doesn't exist
|
||||||
if (directoryTokenData) {
|
if (directoryTokenData) {
|
||||||
const fileInfo = directoryTokenData[claudeMdPath];
|
const fileInfo = getRecordValueByPath(directoryTokenData, claudeMdPath);
|
||||||
if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) {
|
if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) {
|
||||||
// File doesn't exist or has no content - skip it
|
// File doesn't exist or has no content - skip it
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -683,12 +716,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
|
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
|
||||||
injection.estimatedTokens = fileInfo.estimatedTokens;
|
injection.estimatedTokens = fileInfo.estimatedTokens;
|
||||||
newInjections.push(wrapClaudeMdInjection(injection));
|
newInjections.push(wrapClaudeMdInjection(injection));
|
||||||
previousPaths.add(claudeMdPath);
|
rememberPath(previousPaths, claudeMdPath);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior)
|
// Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior)
|
||||||
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
|
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
|
||||||
newInjections.push(wrapClaudeMdInjection(injection));
|
newInjections.push(wrapClaudeMdInjection(injection));
|
||||||
previousPaths.add(claudeMdPath);
|
rememberPath(previousPaths, claudeMdPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -704,12 +737,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
: joinPaths(projectRoot, fileRef.path);
|
: joinPaths(projectRoot, fileRef.path);
|
||||||
|
|
||||||
// Skip if already seen
|
// Skip if already seen
|
||||||
if (previousPaths.has(absolutePath)) {
|
if (hasSeenPath(previousPaths, absolutePath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have token data for this file
|
// 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
|
// Only include files that exist and are under the token limit
|
||||||
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
|
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
|
||||||
|
|
@ -723,7 +756,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
});
|
});
|
||||||
|
|
||||||
newInjections.push(mentionedFileInjection);
|
newInjections.push(mentionedFileInjection);
|
||||||
previousPaths.add(absolutePath);
|
rememberPath(previousPaths, absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -736,11 +769,11 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
? fileRef.path
|
? fileRef.path
|
||||||
: joinPaths(projectRoot, fileRef.path);
|
: joinPaths(projectRoot, fileRef.path);
|
||||||
|
|
||||||
if (previousPaths.has(absolutePath)) {
|
if (hasSeenPath(previousPaths, absolutePath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInfo = mentionedFileTokenData?.get(absolutePath);
|
const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
|
||||||
|
|
||||||
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
|
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
|
||||||
const mentionedFileInjection = createMentionedFileInjection({
|
const mentionedFileInjection = createMentionedFileInjection({
|
||||||
|
|
@ -753,7 +786,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
});
|
});
|
||||||
|
|
||||||
newInjections.push(mentionedFileInjection);
|
newInjections.push(mentionedFileInjection);
|
||||||
previousPaths.add(absolutePath);
|
rememberPath(previousPaths, absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute).
|
* 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 {
|
function isWindowsAbsolutePath(input: string): boolean {
|
||||||
return /^[A-Za-z]:[/\\]/.test(input) || input.startsWith('\\\\') || input.startsWith('//');
|
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
|
// 1. Make relative to project root
|
||||||
if (projectRoot) {
|
if (projectRoot) {
|
||||||
const root = projectRoot.replace(/[/\\]$/, '');
|
const relativePath = getRelativePathWithinPrefix(projectRoot, p);
|
||||||
const caseInsensitive = isWindowsAbsolutePath(p) || isWindowsAbsolutePath(root);
|
if (relativePath) {
|
||||||
const pathForCompare = caseInsensitive ? p.toLowerCase() : p;
|
p = relativePath;
|
||||||
const rootForCompare = caseInsensitive ? root.toLowerCase() : root;
|
|
||||||
if (
|
|
||||||
pathForCompare.startsWith(rootForCompare + '/') ||
|
|
||||||
pathForCompare.startsWith(rootForCompare + '\\')
|
|
||||||
) {
|
|
||||||
p = p.slice(root.length + 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +48,7 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
|
||||||
p = p
|
p = p
|
||||||
.replace(/^\/Users\/[^/]+/, '~')
|
.replace(/^\/Users\/[^/]+/, '~')
|
||||||
.replace(/^\/home\/[^/]+/, '~')
|
.replace(/^\/home\/[^/]+/, '~')
|
||||||
.replace(/^[A-Z]:\\Users\\[^\\]+/i, '~');
|
.replace(/^[A-Z]:[/\\]Users[/\\][^/\\]+/i, '~');
|
||||||
|
|
||||||
// 3. If short enough, return as-is
|
// 3. If short enough, return as-is
|
||||||
if (p.length <= maxLength) return p;
|
if (p.length <= maxLength) return p;
|
||||||
|
|
@ -84,7 +78,7 @@ function inferHomeDir(projectRoot: string): string | null {
|
||||||
const match =
|
const match =
|
||||||
/^(\/Users\/[^/]+)/.exec(projectRoot) ??
|
/^(\/Users\/[^/]+)/.exec(projectRoot) ??
|
||||||
/^(\/home\/[^/]+)/.exec(projectRoot) ??
|
/^(\/home\/[^/]+)/.exec(projectRoot) ??
|
||||||
/^([A-Z]:\\Users\\[^\\]+)/i.exec(projectRoot);
|
/^([A-Z]:[/\\]Users[/\\][^/\\]+)/i.exec(projectRoot);
|
||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,11 +116,7 @@ export function formatProjectPath(path: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsUserPath(input: string): boolean {
|
function isWindowsUserPath(input: string): boolean {
|
||||||
if (input.length < 10) return false;
|
return /^[A-Z]:[/\\]Users[/\\]/i.test(input);
|
||||||
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\\';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
|
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export function joinPath(base: string, ...segments: string[]): string {
|
||||||
export function isPathPrefix(prefix: string, fullPath: string): boolean {
|
export function isPathPrefix(prefix: string, fullPath: string): boolean {
|
||||||
const p = stripTrailingSeparators(normalizePathForComparison(prefix));
|
const p = stripTrailingSeparators(normalizePathForComparison(prefix));
|
||||||
const f = stripTrailingSeparators(normalizePathForComparison(fullPath));
|
const f = stripTrailingSeparators(normalizePathForComparison(fullPath));
|
||||||
|
if (!p) return false;
|
||||||
if (f === p) return true;
|
if (f === p) return true;
|
||||||
// Root prefixes are special: p already ends with "/" ("/" or "c:/").
|
// Root prefixes are special: p already ends with "/" ("/" or "c:/").
|
||||||
if (p === '/') return f.startsWith('/');
|
if (p === '/') return f.startsWith('/');
|
||||||
|
|
@ -85,6 +86,26 @@ export function isPathPrefix(prefix: string, fullPath: string): boolean {
|
||||||
return f.startsWith(p + '/');
|
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. */
|
/** Get the last segment (filename) from a path. */
|
||||||
export function getBasename(filePath: string): string {
|
export function getBasename(filePath: string): string {
|
||||||
const parts = splitPath(filePath);
|
const parts = splitPath(filePath);
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,20 @@ describe('recentProjectOpenHistory', () => {
|
||||||
).toBe(0);
|
).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', () => {
|
it('does not record generated ephemeral project paths', () => {
|
||||||
recordRecentProjectOpenPaths(
|
recordRecentProjectOpenPaths(
|
||||||
['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'],
|
['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'],
|
||||||
|
|
@ -152,4 +166,9 @@ describe('recentProjectOpenHistory', () => {
|
||||||
)
|
)
|
||||||
).toBe(10_000);
|
).toBe(10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores blank project paths without throwing', () => {
|
||||||
|
expect(() => recordRecentProjectOpenPaths([' '], 10_000)).not.toThrow();
|
||||||
|
expect(getRecentProjectLastOpenedAt(makeProject({ primaryPath: ' ', associatedPaths: [' '] }))).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ function createElfBuffer(arch: 'arm64' | 'x64'): Buffer {
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer {
|
function createPortableExecutableBuffer(arch: 'arm64' | 'ia32' | 'x64'): Buffer {
|
||||||
const machine = arch === 'arm64' ? 0xaa64 : 0x8664;
|
const machine = arch === 'arm64' ? 0xaa64 : arch === 'ia32' ? 0x014c : 0x8664;
|
||||||
const buffer = Buffer.alloc(256);
|
const buffer = Buffer.alloc(256);
|
||||||
buffer[0] = 0x4d;
|
buffer[0] = 0x4d;
|
||||||
buffer[1] = 0x5a;
|
buffer[1] = 0x5a;
|
||||||
|
|
@ -224,4 +224,72 @@ describe('electron-builder afterPack', () => {
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).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'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
44
test/main/build/electronBuilderDistScript.test.ts
Normal file
44
test/main/build/electronBuilderDistScript.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
expect(
|
||||||
|
buildElectronBuilderInvocations(['--mac', '--win', '--linux', '--publish', 'never'])
|
||||||
|
).toEqual([
|
||||||
|
{ args: ['--mac', '--publish', 'never'] },
|
||||||
|
{ args: ['--win', '--publish', 'never'] },
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
'--linux',
|
||||||
|
'--publish',
|
||||||
|
'never',
|
||||||
|
'--config.productName=Agent-Teams-UI',
|
||||||
|
'--config.linux.desktop.entry.Name=Agent Teams UI',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the filesystem-safe package name override to Linux-only builds', async () => {
|
||||||
|
expect(buildElectronBuilderInvocations(['--linux', '--publish', 'never'])).toEqual([
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
'--linux',
|
||||||
|
'--publish',
|
||||||
|
'never',
|
||||||
|
'--config.productName=Agent-Teams-UI',
|
||||||
|
'--config.linux.desktop.entry.Name=Agent Teams UI',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves macOS arch-specific builds unchanged', async () => {
|
||||||
|
expect(buildElectronBuilderInvocations(['--mac', '--arm64', '--publish', 'never'])).toEqual([
|
||||||
|
{ args: ['--mac', '--arm64', '--publish', 'never'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import * as path from 'path';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('fs/promises', () => ({
|
vi.mock('fs/promises', () => ({
|
||||||
|
access: vi.fn(),
|
||||||
readdir: vi.fn(),
|
readdir: vi.fn(),
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
stat: vi.fn(),
|
stat: vi.fn(),
|
||||||
|
|
@ -36,6 +37,7 @@ describe('FileSearchService', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(fs.access).mockRejectedValue(new Error('not a git repository') as never);
|
||||||
service = new FileSearchService();
|
service = new FileSearchService();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -173,4 +175,27 @@ describe('FileSearchService', () => {
|
||||||
expect(result.results[0].matches[1].column).toBe(4);
|
expect(result.results[0].matches[1].column).toBe(4);
|
||||||
expect(result.results[0].matches[2].column).toBe(8);
|
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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,7 +40,10 @@ vi.mock('@renderer/components/chat/viewers/FileLink', () => ({
|
||||||
isRelativeUrl: () => false,
|
isRelativeUrl: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import {
|
||||||
|
MarkdownViewer,
|
||||||
|
resolveRelativePath,
|
||||||
|
} from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
|
||||||
describe('MarkdownViewer code blocks', () => {
|
describe('MarkdownViewer code blocks', () => {
|
||||||
afterEach(() => {
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -121,4 +121,19 @@ describe('resolveFileLinkPath', () => {
|
||||||
resolveFileLinkPath('/Users/belief/dev/projects/your_posts/docs/roadmap.md', PROJECT_PATH)
|
resolveFileLinkPath('/Users/belief/dev/projects/your_posts/docs/roadmap.md', PROJECT_PATH)
|
||||||
).toBe('/Users/belief/dev/projects/your_posts/docs/roadmap.md');
|
).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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import type { QuickOpenFile } from '@shared/types/editor';
|
||||||
|
|
||||||
|
|
@ -91,14 +96,42 @@ describe('filterFileSuggestions', () => {
|
||||||
expect(results.map((r) => r.name)).toEqual(['auth.ts', 'database.ts']);
|
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', () => {
|
it('returns results in file list order', () => {
|
||||||
const results = filterFileSuggestions(FILES, '.ts');
|
const results = filterFileSuggestions(FILES, '.ts');
|
||||||
expect(results[0].name).toBe('index.ts');
|
expect(results[0].name).toBe('index.ts');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quotes inserted paths that contain spaces', () => {
|
it('quotes inserted paths that contain spaces', () => {
|
||||||
expect(formatFileMentionPath('src/My Component/App.tsx')).toBe(
|
expect(formatFileMentionPath('src/My Component/App.tsx')).toBe('"src/My Component/App.tsx"');
|
||||||
'"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/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ describe('resolveFilePath', () => {
|
||||||
|
|
||||||
it('resolves dot-prefixed relative paths', () => {
|
it('resolves dot-prefixed relative paths', () => {
|
||||||
expect(resolveFilePath('/repo', './src/app.ts')).toBe('/repo/src/app.ts');
|
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', () => {
|
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', () => {
|
it('passes through tilde paths as-is', () => {
|
||||||
expect(resolveFilePath('/repo', '~/some/directory')).toBe('~/some/directory');
|
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', () => {
|
it('does not treat tilde in the middle as special', () => {
|
||||||
expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar');
|
expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
detectClaudeMdFromFilePath,
|
detectClaudeMdFromFilePath,
|
||||||
|
extractUserMentionPaths,
|
||||||
getDirectory,
|
getDirectory,
|
||||||
getParentDirectory,
|
getParentDirectory,
|
||||||
|
processSessionClaudeMd,
|
||||||
} from '@renderer/utils/claudeMdTracker';
|
} from '@renderer/utils/claudeMdTracker';
|
||||||
|
|
||||||
describe('claudeMdTracker path helpers', () => {
|
describe('claudeMdTracker path helpers', () => {
|
||||||
|
|
@ -67,6 +69,13 @@ describe('claudeMdTracker path helpers', () => {
|
||||||
expect(result).toHaveLength(2);
|
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', () => {
|
it('uses correct separator for generated paths', () => {
|
||||||
const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo');
|
const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo');
|
||||||
for (const p of unixResult) {
|
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']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
66
test/renderer/utils/contextTracker.test.ts
Normal file
66
test/renderer/utils/contextTracker.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,25 +8,44 @@ import {
|
||||||
|
|
||||||
describe('pathDisplay Windows paths', () => {
|
describe('pathDisplay Windows paths', () => {
|
||||||
it('treats lowercase drive paths as absolute', () => {
|
it('treats lowercase drive paths as absolute', () => {
|
||||||
expect(resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')).toBe(
|
expect(
|
||||||
'c:\\Users\\Alice\\repo\\src\\app.ts'
|
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', () => {
|
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'
|
'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', () => {
|
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');
|
||||||
|
expect(formatProjectPath('C:/Users/Alice/repo')).toBe('~/repo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves home paths from lowercase Windows user roots', () => {
|
it('resolves home paths from lowercase Windows user roots', () => {
|
||||||
expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe(
|
expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe(
|
||||||
'c:\\users\\Alice\\repo\\src\\app.ts'
|
'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', () => {
|
it('resolves relative paths using the project root separator', () => {
|
||||||
|
|
|
||||||
38
test/shared/utils/platformPath.test.ts
Normal file
38
test/shared/utils/platformPath.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue