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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion electron/agentSessions/gemini/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe("parseGeminiSessionFile(超大文件 summaryOnly 预览兜底)", ()
id: "m1",
timestamp: startTime,
type: "user",
content: "`/mnt/c/Users/52628/AppData/Roaming/codexflow/assets/CodexFlow/image-20260131-003734-k8xy.png`\n\n真实首条:你好",
content: "`/mnt/c/Users/example-user/AppData/Roaming/codexflow/assets/CodexFlow/image-20260131-003734-k8xy.png`\n\n真实首条:你好",
},
{
id: "m2",
Expand Down
82 changes: 82 additions & 0 deletions electron/agentSessions/shared/path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk)

import { describe, expect, it } from "vitest";
import { dirKeyFromCwd, findBestMatchingDirKeyScope, pathMatchesDirKeyScope, tidyPathCandidate } from "./path";

const TEST_PARENT_SCOPE = "/mnt/c/users/example-user";
const TEST_CODEX_SCOPE = `${TEST_PARENT_SCOPE}/.codex/worktrees/135b/codexflow`;
const TEST_CLAUDE_SCOPE = `${TEST_PARENT_SCOPE}/projects/monorepo/apps/claude-demo`;
const TEST_GEMINI_SCOPE = `${TEST_PARENT_SCOPE}/projects/monorepo/packages/gemini-demo`;

describe("tidyPathCandidate", () => {
it("保留 Windows 盘符根目录的尾部语义", () => {
expect(tidyPathCandidate("C:\\")).toBe("C:\\");
expect(tidyPathCandidate("C:")).toBe("C:\\");
});

it("保留 /mnt 盘根目录并去掉多余尾斜杠", () => {
expect(tidyPathCandidate("/mnt/c/")).toBe("/mnt/c");
});
});

describe("dirKeyFromCwd", () => {
it("将 Windows 盘符根目录规范化为 /mnt/<drive>", () => {
expect(dirKeyFromCwd("C:\\")).toBe("/mnt/c");
expect(dirKeyFromCwd("C:")).toBe("/mnt/c");
});
});

describe("pathMatchesDirKeyScope", () => {
it("根目录 scope 仅允许精确匹配", () => {
expect(pathMatchesDirKeyScope("/mnt/c", "/mnt/c")).toBe(true);
expect(pathMatchesDirKeyScope("c:", "/mnt/c")).toBe(true);
expect(pathMatchesDirKeyScope(TEST_PARENT_SCOPE, "/mnt/c")).toBe(false);
});

it("普通项目目录仍允许匹配子目录", () => {
expect(pathMatchesDirKeyScope("/mnt/g/projects/demo/src", "/mnt/g/projects/demo")).toBe(true);
});
});

describe("findBestMatchingDirKeyScope", () => {
it("父子项目同时命中时优先返回更具体的子项目", () => {
expect(findBestMatchingDirKeyScope(
TEST_CODEX_SCOPE,
[
TEST_PARENT_SCOPE,
TEST_CODEX_SCOPE,
],
)).toBe(TEST_CODEX_SCOPE);
});

it("对 codex、claude、gemini 的嵌套项目路径都优先返回子项目", () => {
const cases = [
{
providerId: "codex",
candidate: TEST_CODEX_SCOPE,
expected: TEST_CODEX_SCOPE,
},
{
providerId: "claude",
candidate: TEST_CLAUDE_SCOPE,
expected: TEST_CLAUDE_SCOPE,
},
{
providerId: "gemini",
candidate: TEST_GEMINI_SCOPE,
expected: TEST_GEMINI_SCOPE,
},
] as const;

for (const item of cases) {
expect(findBestMatchingDirKeyScope(
item.candidate,
[
TEST_PARENT_SCOPE,
item.expected,
],
), `${item.providerId} should prefer nested project scope`).toBe(item.expected);
}
});
});
96 changes: 93 additions & 3 deletions electron/agentSessions/shared/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@
import path from "node:path";
import { isUNCPath, uncToWsl } from "../../wsl";

/**
* 中文说明:去掉路径尾部分隔符,但保留“根目录”语义。
* - `C:` / `C:\` / `C:/` 统一保留为 `C:\`;
* - `/mnt/c/` 保留为 `/mnt/c`;
* - `/` 保留为 `/`;
* - 其余路径去掉多余的尾部分隔符。
*/
function trimTrailingSeparatorsPreserveRoot(value: string): string {
try {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = raw.replace(/\//g, "\\");
const driveRootMatch = normalized.match(/^([a-zA-Z]):(?:\\+)?$/);
if (driveRootMatch) return `${driveRootMatch[1].toUpperCase()}:\\`;
const posixLike = raw.replace(/\\/g, "/");
const mountRootMatch = posixLike.match(/^\/mnt\/([a-zA-Z])\/?$/);
if (mountRootMatch) return `/mnt/${mountRootMatch[1].toLowerCase()}`;
if (/^\/+$/.test(posixLike)) return "/";
return raw.replace(/[\\/]+$/g, "");
} catch {
return String(value || "").trim();
}
}

/**
* 中文说明:将 dirKey/scope 统一规范化为可比较的 key。
* - `C:` / `C:\foo` 转为 `/mnt/c` 风格;
* - 其余路径统一为小写 POSIX 风格。
*/
function normalizeDirKeyScopeValue(value: string): string {
const raw = String(value || "").trim();
if (!raw) return "";
const driveMatch = raw.match(/^([a-zA-Z]):(?:[\\/](.*))?$/);
if (driveMatch) {
const drive = driveMatch[1].toLowerCase();
const rest = String(driveMatch[2] || "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "");
return rest ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`;
}
const normalized = raw.replace(/\\/g, "/").replace(/\/+/g, "/");
if (normalized === "/") return "/";
return normalized.replace(/\/+$/, "").toLowerCase();
}

/**
* 清理从日志/JSON 中提取的路径候选:
* - 去除首尾空白与包裹引号
Expand All @@ -18,13 +61,57 @@ export function tidyPathCandidate(value: string): string {
.replace(/^'|'$/g, "")
.trim();
s = s.replace(/\\\\/g, "\\").trim();
s = s.replace(/[\\/]+$/g, "");
s = trimTrailingSeparatorsPreserveRoot(s);
return s;
} catch {
return String(value || "").trim();
}
}

/**
* 中文说明:判断某个规范化后的目录 scope 是否应只允许“精确匹配”。
* - 盘符根目录(如 `/mnt/c`)不应吞掉整盘所有子目录会话;
* - POSIX 根目录(`/`)同样仅允许匹配自身。
*/
export function isExactMatchOnlyDirKeyScope(scopeKey: string): boolean {
const scope = normalizeDirKeyScopeValue(scopeKey);
if (!scope) return false;
if (scope === "/") return true;
if (/^\/mnt\/[a-z]$/.test(scope)) return true;
return false;
}

/**
* 中文说明:判断候选 dirKey 是否属于指定 scope。
* - 普通项目目录:允许“自身或子目录”命中;
* - 根目录 scope:仅允许精确命中,避免 `C:\` 吞掉 `C:\Users\...`。
*/
export function pathMatchesDirKeyScope(candidateKey: string, scopeKey: string): boolean {
const candidate = normalizeDirKeyScopeValue(candidateKey);
const scope = normalizeDirKeyScopeValue(scopeKey);
if (!candidate || !scope) return false;
if (candidate === scope) return true;
if (isExactMatchOnlyDirKeyScope(scope)) return false;
return candidate.startsWith(`${scope}/`);
}

/**
* 中文说明:在多个项目 scope 中,为候选路径选择“最具体”的命中项。
* - 无命中时返回空串;
* - 父子项目同时命中时,优先返回路径更长的子项目 scope。
*/
export function findBestMatchingDirKeyScope(candidateKey: string, scopeKeys: readonly string[]): string {
const candidate = normalizeDirKeyScopeValue(candidateKey);
if (!candidate) return "";
let best = "";
for (const rawScope of scopeKeys) {
const scope = normalizeDirKeyScopeValue(rawScope);
if (!scope || !pathMatchesDirKeyScope(candidate, scope)) continue;
if (!best || scope.length > best.length) best = scope;
}
return best;
}

/**
* 从文件路径获取用于项目归属匹配的 dirKey(优先归一为 WSL 风格)。
*/
Expand Down Expand Up @@ -54,8 +141,11 @@ export function dirKeyFromCwd(dirPath: string): string {
const info = uncToWsl(d);
if (info) d = info.wslPath;
} else {
const m = d.match(/^([a-zA-Z]):\\(.*)$/);
if (m) d = `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, "/")}`;
const m = d.match(/^([a-zA-Z]):(?:\\(.*))?$/);
if (m) {
const rest = String(m[2] || "").replace(/\\/g, "/");
d = rest ? `/mnt/${m[1].toLowerCase()}/${rest}` : `/mnt/${m[1].toLowerCase()}`;
}
}
return d.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "").toLowerCase();
} catch {
Expand Down
5 changes: 4 additions & 1 deletion electron/debugConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type DebugConfig = {
version: number;
global: {
diagLog: boolean; // 主/渲染进程诊断日志(写入 perf.log)
whiteScreenLog: boolean; // 白屏/强制刷新/渲染恢复诊断(默认开启)
openDevtools: boolean; // 启动时强制打开 DevTools
};
renderer: {
Expand Down Expand Up @@ -125,7 +126,7 @@ function stripJsonComments(input: string): string {
export function getDefaultDebugConfig(): DebugConfig {
return {
version: 1,
global: { diagLog: false, openDevtools: false },
global: { diagLog: false, whiteScreenLog: true, openDevtools: false },
renderer: { uiDebug: false, notifications: { debug: false, menu: "auto" }, atSearchDebug: false },
terminal: { frontend: { debug: false, disablePin: false }, pty: { debug: false } },
fileIndex: {
Expand All @@ -150,6 +151,7 @@ function renderJsonc(cfg: DebugConfig): string {
lines.push(" // 全局与日志");
lines.push(" \"global\": {");
lines.push(" \"diagLog\": " + (cfg.global.diagLog ? "true" : "false") + ", // 主/渲染进程诊断写 perf.log");
lines.push(" \"whiteScreenLog\": " + (cfg.global.whiteScreenLog ? "true" : "false") + ", // 白屏/强制刷新/恢复链路日志,默认建议保持开启");
lines.push(" \"openDevtools\": " + (cfg.global.openDevtools ? "true" : "false") + " // 启动强制打开 DevTools(需重启)");
lines.push(" },");
lines.push(" // 渲染层");
Expand Down Expand Up @@ -195,6 +197,7 @@ function merge(a: DebugConfig, b: Partial<DebugConfig> | null | undefined): Debu
try { x.version = Number((b as any).version ?? a.version ?? 1); } catch { x.version = a.version; }
x.global = {
diagLog: pick((b as any)?.global?.diagLog, a.global.diagLog),
whiteScreenLog: pick((b as any)?.global?.whiteScreenLog, a.global.whiteScreenLog),
openDevtools: pick((b as any)?.global?.openDevtools, a.global.openDevtools),
} as any;
x.renderer = {
Expand Down
35 changes: 21 additions & 14 deletions electron/gemini/projectTemp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import {
} from "./projectTemp";
import { getDistroHomeSubPathUNCAsync } from "../wsl";

const TEST_WSL_HOME = "/home/example-user";
const TEST_WSL_PROJECT_ROOT = `${TEST_WSL_HOME}/demo`;
const TEST_WSL_GEMINI_HOME = `${TEST_WSL_HOME}/.gemini`;
const TEST_WSL_CUSTOM_GEMINI_HOME = `${TEST_WSL_HOME}/.gemini-custom`;
const TEST_UNC_GEMINI_HOME = "\\\\wsl.localhost\\Ubuntu-24.04\\home\\example-user\\.gemini";
const TEST_UNC_CUSTOM_GEMINI_HOME = "\\\\wsl.localhost\\Ubuntu-24.04\\home\\example-user\\.gemini-custom";

vi.mock("../wsl", () => ({
getDistroHomeSubPathUNCAsync: vi.fn(),
}));
Expand Down Expand Up @@ -63,10 +70,10 @@ describe("Gemini 项目临时目录解析", () => {

const markerDir = path.join(geminiHome, "tmp", "demo-worktree");
await fsp.mkdir(markerDir, { recursive: true });
await fsp.writeFile(path.join(markerDir, ".project_root"), "/home/lulu/demo", "utf8");
await fsp.writeFile(path.join(markerDir, ".project_root"), TEST_WSL_PROJECT_ROOT, "utf8");

const projectId = await resolveGeminiProjectIdentifier({
projectWslRoot: "/home/lulu/demo",
projectWslRoot: TEST_WSL_PROJECT_ROOT,
runtimeEnv: "wsl",
});

Expand All @@ -89,47 +96,47 @@ describe("Gemini 项目临时目录解析", () => {

it("WSL 运行时将 Gemini temp 目录解析为 UNC 路径", async () => {
vi.spyOn(os, "platform").mockReturnValue("win32" as any);
vi.mocked(getDistroHomeSubPathUNCAsync).mockResolvedValue("\\\\wsl.localhost\\Ubuntu-24.04\\home\\lulu\\.gemini");
vi.mocked(getDistroHomeSubPathUNCAsync).mockResolvedValue(TEST_UNC_GEMINI_HOME);
const originalReadFile = fsp.readFile.bind(fsp);
vi.spyOn(fsp, "readFile").mockImplementation(async (targetPath: any, ...args: any[]) => {
const normalizedPath = String(targetPath || "");
if (normalizedPath === "\\\\wsl.localhost\\Ubuntu-24.04\\home\\lulu\\.gemini\\projects.json")
return JSON.stringify({ projects: { "/home/lulu/demo": "demo" } }) as any;
if (normalizedPath === `${TEST_UNC_GEMINI_HOME}\\projects.json`)
return JSON.stringify({ projects: { [TEST_WSL_PROJECT_ROOT]: "demo" } }) as any;
return await (originalReadFile as any)(targetPath, ...args);
});

const tempRoot = await resolveGeminiProjectTempRootWinPath({
projectWslRoot: "/home/lulu/demo",
projectWslRoot: TEST_WSL_PROJECT_ROOT,
runtimeEnv: "wsl",
distro: "Ubuntu-24.04",
});
const imageRoot = await resolveGeminiImageDirWinPath({
projectWslRoot: "/home/lulu/demo",
projectWslRoot: TEST_WSL_PROJECT_ROOT,
runtimeEnv: "wsl",
distro: "Ubuntu-24.04",
});

expect(tempRoot).toBe("\\\\wsl.localhost\\Ubuntu-24.04\\home\\lulu\\.gemini\\tmp\\demo");
expect(imageRoot).toBe("\\\\wsl.localhost\\Ubuntu-24.04\\home\\lulu\\.gemini\\tmp\\demo\\images");
expect(tempRoot).toBe(`${TEST_UNC_GEMINI_HOME}\\tmp\\demo`);
expect(imageRoot).toBe(`${TEST_UNC_GEMINI_HOME}\\tmp\\demo\\images`);
});

it("Windows 主进程在 WSL 模式下会把 POSIX 版 GEMINI_CLI_HOME 转成 UNC", async () => {
vi.spyOn(os, "platform").mockReturnValue("win32" as any);
process.env.GEMINI_CLI_HOME = "/home/lulu/.gemini-custom";
process.env.GEMINI_CLI_HOME = TEST_WSL_CUSTOM_GEMINI_HOME;
const originalReadFile = fsp.readFile.bind(fsp);
vi.spyOn(fsp, "readFile").mockImplementation(async (targetPath: any, ...args: any[]) => {
const normalizedPath = String(targetPath || "");
if (normalizedPath === "\\\\wsl.localhost\\Ubuntu-24.04\\home\\lulu\\.gemini-custom\\projects.json")
return JSON.stringify({ projects: { "/home/lulu/demo": "demo" } }) as any;
if (normalizedPath === `${TEST_UNC_CUSTOM_GEMINI_HOME}\\projects.json`)
return JSON.stringify({ projects: { [TEST_WSL_PROJECT_ROOT]: "demo" } }) as any;
return await (originalReadFile as any)(targetPath, ...args);
});

const tempRoot = await resolveGeminiProjectTempRootWinPath({
projectWslRoot: "/home/lulu/demo",
projectWslRoot: TEST_WSL_PROJECT_ROOT,
runtimeEnv: "wsl",
distro: "Ubuntu-24.04",
});

expect(tempRoot).toBe("\\\\wsl.localhost\\Ubuntu-24.04\\home\\lulu\\.gemini-custom\\tmp\\demo");
expect(tempRoot).toBe(`${TEST_UNC_CUSTOM_GEMINI_HOME}\\tmp\\demo`);
});
});
5 changes: 4 additions & 1 deletion electron/gemini/projectTemp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ async function resolveConfiguredGeminiHomeForWindowsWsl(
/**
* 中文说明:解析当前运行环境下、主进程可访问的 Gemini 根目录。
* - 非 WSL 场景优先直接使用 `GEMINI_CLI_HOME`
* - Windows 主进程 + WSL 运行时仅在路径可访问时复用 `GEMINI_CLI_HOME`;POSIX 路径会转成 UNC
* - Windows 主进程 + WSL 运行时若 `GEMINI_CLI_HOME` 已是 Windows/UNC 路径,则无需 distro 也可直接复用
* - Windows 主进程 + WSL 运行时若 `GEMINI_CLI_HOME` 为 POSIX 路径,则转成 UNC
* - WSL on Windows 返回 UNC 路径
* - WSL 单元测试/非 Windows 环境优先使用 `GEMINI_CLI_HOME`,否则返回 POSIX `~/.gemini`
* - Windows/Pwsh 返回本机 `%USERPROFILE%\\.gemini`
Expand All @@ -331,6 +332,8 @@ async function resolveGeminiHomePath(
if (runtimeEnv !== "wsl") return resolveGeminiHomeWindows();
if (os.platform() !== "win32")
return configuredHome || path.posix.join(os.homedir(), ".gemini");
if (configuredHome && isWindowsStylePath(configuredHome))
return tidyPathCandidate(configuredHome);
const distro = String(options.distro || "").trim();
if (!distro) return null;
if (configuredHome) {
Expand Down
Loading
Loading