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
26 changes: 26 additions & 0 deletions src/node/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,32 @@ describe("Config", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

describe("loadConfigOrDefault with trailing slash migration", () => {
it("should strip trailing slashes from project paths on load", () => {
// Create config file with trailing slashes in project paths
const configFile = path.join(tempDir, "config.json");
const corruptedConfig = {
projects: [
["/home/user/project/", { workspaces: [] }],
["/home/user/another//", { workspaces: [] }],
["/home/user/clean", { workspaces: [] }],
],
};
fs.writeFileSync(configFile, JSON.stringify(corruptedConfig));

// Load config - should migrate paths
const loaded = config.loadConfigOrDefault();

// Verify paths are normalized (no trailing slashes)
const projectPaths = Array.from(loaded.projects.keys());
expect(projectPaths).toContain("/home/user/project");
expect(projectPaths).toContain("/home/user/another");
expect(projectPaths).toContain("/home/user/clean");
expect(projectPaths).not.toContain("/home/user/project/");
expect(projectPaths).not.toContain("/home/user/another//");
});
});

describe("generateStableId", () => {
it("should generate a 10-character hex string", () => {
const id = config.generateStableId();
Expand Down
11 changes: 8 additions & 3 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility";
import { getMuxHome } from "@/common/constants/paths";
import { PlatformPaths } from "@/common/utils/paths";
import { stripTrailingSlashes } from "@/node/utils/pathUtils";

// Re-export project types from dedicated types file (for preload usage)
export type { Workspace, ProjectConfig, ProjectsConfig };
Expand Down Expand Up @@ -56,9 +57,13 @@ export class Config {

// Config is stored as array of [path, config] pairs
if (parsed.projects && Array.isArray(parsed.projects)) {
const projectsMap = new Map<string, ProjectConfig>(
parsed.projects as Array<[string, ProjectConfig]>
);
const rawPairs = parsed.projects as Array<[string, ProjectConfig]>;
// Migrate: normalize project paths by stripping trailing slashes
// This fixes configs created with paths like "/home/user/project/"
const normalizedPairs = rawPairs.map(([projectPath, projectConfig]) => {
return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig];
});
const projectsMap = new Map<string, ProjectConfig>(normalizedPairs);
return {
projects: projectsMap,
};
Expand Down
18 changes: 18 additions & 0 deletions src/node/utils/pathUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,23 @@ describe("pathUtils", () => {
expect(result.valid).toBe(true);
expect(result.expandedPath).toBe(tempDir);
});

it("should strip trailing slashes from path", async () => {
// Create .git directory for validation
// eslint-disable-next-line local/no-sync-fs-methods -- Test setup only
fs.mkdirSync(path.join(tempDir, ".git"));

// Test with single trailing slash
const resultSingle = await validateProjectPath(`${tempDir}/`);
expect(resultSingle.valid).toBe(true);
expect(resultSingle.expandedPath).toBe(tempDir);
expect(resultSingle.expandedPath).not.toMatch(/[/\\]$/);

// Test with multiple trailing slashes
const resultMultiple = await validateProjectPath(`${tempDir}//`);
expect(resultMultiple.valid).toBe(true);
expect(resultMultiple.expandedPath).toBe(tempDir);
expect(resultMultiple.expandedPath).not.toMatch(/[/\\]$/);
});
});
});
19 changes: 17 additions & 2 deletions src/node/utils/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export function expandTilde(inputPath: string): string {
return PlatformPaths.expandHome(inputPath);
}

/**
* Strip trailing slashes from a path.
* path.normalize() preserves a single trailing slash which breaks basename extraction.
*
* @param inputPath - Path that may have trailing slashes
* @returns Path without trailing slashes
*
* @example
* stripTrailingSlashes("/home/user/project/") // => "/home/user/project"
* stripTrailingSlashes("/home/user/project//") // => "/home/user/project"
*/
export function stripTrailingSlashes(inputPath: string): string {
return inputPath.replace(/[/\\]+$/, "");
}

/**
* Validate that a project path exists, is a directory, and is a git repository
* Automatically expands tilde and normalizes the path
Expand All @@ -47,8 +62,8 @@ export async function validateProjectPath(inputPath: string): Promise<PathValida
// Expand tilde if present
const expandedPath = expandTilde(inputPath);

// Normalize to resolve any .. or . in the path
const normalizedPath = path.normalize(expandedPath);
// Normalize to resolve any .. or . in the path, then strip trailing slashes
const normalizedPath = stripTrailingSlashes(path.normalize(expandedPath));

// Check if path exists
try {
Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/scenarios/projectPath.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { electronTest as test, electronExpect as expect } from "../electronTest";
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";

test.skip(
({ browserName }) => browserName !== "chromium",
"Electron scenario runs on chromium only"
);

test.describe("Project Path Handling", () => {
test("project with trailing slash displays correctly", async ({ workspace, page }) => {
const { configRoot } = workspace;
const srcDir = path.join(configRoot, "src");
const sessionsDir = path.join(configRoot, "sessions");

// Create a project path WITH trailing slash to simulate the bug
const projectPathWithSlash = path.join(configRoot, "fixtures", "trailing-slash-project") + "/";
const projectName = "trailing-slash-project"; // Expected extracted name
const workspaceBranch = "test-branch";
const workspacePath = path.join(srcDir, projectName, workspaceBranch);

// Create directories
fs.mkdirSync(path.dirname(projectPathWithSlash), { recursive: true });
fs.mkdirSync(projectPathWithSlash, { recursive: true });
fs.mkdirSync(workspacePath, { recursive: true });
fs.mkdirSync(sessionsDir, { recursive: true });

// Initialize git repos
for (const repoPath of [projectPathWithSlash, workspacePath]) {
spawnSync("git", ["init", "-q"], { cwd: repoPath });
spawnSync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath });
spawnSync("git", ["config", "user.name", "Test"], { cwd: repoPath });
spawnSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: repoPath });
}

// Write config with trailing slash in project path - this tests the migration
const configPayload = {
projects: [[projectPathWithSlash, { workspaces: [{ path: workspacePath }] }]],
};
fs.writeFileSync(path.join(configRoot, "config.json"), JSON.stringify(configPayload, null, 2));

// Create workspace session with metadata
const workspaceId = `${projectName}-${workspaceBranch}`;
const workspaceSessionDir = path.join(sessionsDir, workspaceId);
fs.mkdirSync(workspaceSessionDir, { recursive: true });
fs.writeFileSync(
path.join(workspaceSessionDir, "metadata.json"),
JSON.stringify({
id: workspaceId,
name: workspaceBranch,
projectName,
projectPath: projectPathWithSlash,
})
);
fs.writeFileSync(path.join(workspaceSessionDir, "chat.jsonl"), "");

// Reload the page to pick up the new config
await page.reload();
await page.waitForLoadState("domcontentloaded");

// Find the project in the sidebar - it should show the project name, not empty
const navigation = page.getByRole("navigation", { name: "Projects" });
await expect(navigation).toBeVisible();

// The project name should be visible (extracted correctly despite trailing slash)
// If the bug was present, we'd see an empty project name or just "/"
await expect(navigation.getByText(projectName)).toBeVisible();

// Verify the workspace is also visible under the project
const projectItem = navigation.locator('[role="button"][aria-controls]').first();
await expect(projectItem).toBeVisible();

// Expand to see workspace
const expandButton = projectItem.getByRole("button", { name: /expand project/i });
if (await expandButton.isVisible()) {
await expandButton.click();
}

// Workspace branch should be visible
await expect(navigation.getByText(workspaceBranch)).toBeVisible();
});
});