Skip to content

Commit 1745365

Browse files
committed
feat: add cross-platform path handling with PlatformPaths utility
- Add PlatformPaths class to handle Windows and Unix paths correctly - Replace all direct path operations with PlatformPaths methods - Support Windows drive letters, UNC paths, and backslash separators - Maintain consistent path abbreviation across platforms - Add comprehensive tests for all path operations
1 parent 9e6ceac commit 1745365

File tree

13 files changed

+468
-178
lines changed

13 files changed

+468
-178
lines changed

src/components/ProjectSidebar.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend";
99
import { useDrag, useDrop, useDragLayer } from "react-dnd";
1010
import { sortProjectsByOrder, reorderProjects, normalizeOrder } from "@/utils/projectOrdering";
1111
import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
12-
import { abbreviatePath, splitAbbreviatedPath } from "@/utils/ui/pathAbbreviation";
12+
import { PlatformPaths } from "@/utils/paths";
1313
import {
1414
partitionWorkspacesByAge,
1515
formatOldWorkspaceThreshold,
@@ -131,8 +131,8 @@ const ProjectDragLayer: React.FC = () => {
131131

132132
if (!isDragging || !currentOffset || !item?.projectPath) return null;
133133

134-
const abbrevPath = abbreviatePath(item.projectPath);
135-
const { dirPath, basename } = splitAbbreviatedPath(abbrevPath);
134+
const abbrevPath = PlatformPaths.abbreviate(item.projectPath);
135+
const { dirPath, basename } = PlatformPaths.splitAbbreviated(abbrevPath);
136136

137137
return (
138138
<div className="pointer-events-none fixed inset-0 z-[9999] cursor-grabbing">
@@ -238,7 +238,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
238238
if (!path || typeof path !== "string") {
239239
return "Unknown";
240240
}
241-
return path.split("/").pop() ?? path.split("\\").pop() ?? path;
241+
return PlatformPaths.getProjectName(path);
242242
};
243243

244244
const toggleProject = (projectPath: string) => {
@@ -498,8 +498,9 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
498498
<TooltipWrapper inline>
499499
<div className="text-muted-dark font-monospace truncate text-sm leading-tight">
500500
{(() => {
501-
const abbrevPath = abbreviatePath(projectPath);
502-
const { dirPath, basename } = splitAbbreviatedPath(abbrevPath);
501+
const abbrevPath = PlatformPaths.abbreviate(projectPath);
502+
const { dirPath, basename } =
503+
PlatformPaths.splitAbbreviated(abbrevPath);
503504
return (
504505
<>
505506
<span>{dirPath}</span>

src/config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Secret, SecretsConfig } from "./types/secrets";
88
import type { Workspace, ProjectConfig, ProjectsConfig } from "./types/project";
99
import { DEFAULT_RUNTIME_CONFIG } from "./constants/workspace";
1010
import { getMuxHome } from "./constants/paths";
11+
import { PlatformPaths } from "./utils/paths";
1112

1213
// Re-export project types from dedicated types file (for preload usage)
1314
export type { Workspace, ProjectConfig, ProjectsConfig };
@@ -96,7 +97,7 @@ export class Config {
9697
}
9798

9899
private getProjectName(projectPath: string): string {
99-
return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
100+
return PlatformPaths.getProjectName(projectPath);
100101
}
101102

102103
/**
@@ -120,8 +121,7 @@ export class Config {
120121
*/
121122
generateLegacyId(projectPath: string, workspacePath: string): string {
122123
const projectBasename = this.getProjectName(projectPath);
123-
const workspaceBasename =
124-
workspacePath.split("/").pop() ?? workspacePath.split("\\").pop() ?? "unknown";
124+
const workspaceBasename = PlatformPaths.basename(workspacePath);
125125
return `${projectBasename}-${workspaceBasename}`;
126126
}
127127

src/debug/agentSessionCli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import assert from "@/utils/assert";
44
import * as fs from "fs/promises";
55
import * as path from "path";
6+
import { PlatformPaths } from "../utils/paths";
67
import { parseArgs } from "util";
78
import { Config } from "@/config";
89
import { HistoryService } from "@/services/historyService";
@@ -168,8 +169,8 @@ async function main(): Promise<void> {
168169
const projectPathRaw = values["project-path"];
169170
const projectName =
170171
typeof projectPathRaw === "string" && projectPathRaw.trim().length > 0
171-
? path.basename(path.resolve(projectPathRaw.trim()))
172-
: path.basename(path.dirname(workspacePath)) || "unknown";
172+
? PlatformPaths.basename(path.resolve(projectPathRaw.trim()))
173+
: PlatformPaths.basename(path.dirname(workspacePath)) || "unknown";
173174

174175
const messageArg =
175176
values.message && values.message.trim().length > 0 ? values.message : undefined;

src/debug/list-workspaces.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defaultConfig } from "@/config";
22
import * as path from "path";
3+
import { PlatformPaths } from "../utils/paths";
34
import * as fs from "fs";
45
import { getMuxSessionsDir } from "@/constants/paths";
56

@@ -10,13 +11,13 @@ export function listWorkspacesCommand() {
1011
console.log("Projects in config:", config.projects.size);
1112

1213
for (const [projectPath, project] of config.projects) {
13-
const projectName = path.basename(projectPath);
14+
const projectName = PlatformPaths.basename(projectPath);
1415
console.log(`\nProject: ${projectName}`);
1516
console.log(` Path: ${projectPath}`);
1617
console.log(` Workspaces: ${project.workspaces.length}`);
1718

1819
for (const workspace of project.workspaces) {
19-
const dirName = path.basename(workspace.path);
20+
const dirName = PlatformPaths.basename(workspace.path);
2021
console.log(` - Directory: ${dirName}`);
2122
if (workspace.id) {
2223
console.log(` ID: ${workspace.id}`);

src/runtime/tildeExpansion.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
* For local paths, tildes should be expanded to actual file system paths.
99
*/
1010

11-
import * as os from "os";
12-
import * as path from "path";
11+
import { PlatformPaths } from "../utils/paths";
1312

1413
/**
1514
* Expand tilde to actual home directory path for local file system operations.
@@ -28,13 +27,7 @@ import * as path from "path";
2827
* expandTilde("/abs/path") // => "/abs/path"
2928
*/
3029
export function expandTilde(filePath: string): string {
31-
if (filePath === "~") {
32-
return os.homedir();
33-
} else if (filePath.startsWith("~/")) {
34-
return path.join(os.homedir(), filePath.slice(2));
35-
} else {
36-
return filePath;
37-
}
30+
return PlatformPaths.expandHome(filePath);
3831
}
3932

4033
/**

src/services/agentSession.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from "@/utils/assert";
22
import { EventEmitter } from "events";
33
import * as path from "path";
4+
import { PlatformPaths } from "@/utils/paths";
45
import { createMuxMessage } from "@/types/message";
56
import type { Config } from "@/config";
67
import type { AIService } from "@/services/aiService";
@@ -211,11 +212,11 @@ export class AgentSession {
211212
if (isUnderSrcBaseDir) {
212213
// Standard worktree mode: workspace is under ~/.cmux/src/project/branch
213214
derivedProjectPath = path.dirname(normalizedWorkspacePath);
214-
workspaceName = path.basename(normalizedWorkspacePath);
215+
workspaceName = PlatformPaths.basename(normalizedWorkspacePath);
215216
derivedProjectName =
216217
projectName && projectName.trim().length > 0
217218
? projectName.trim()
218-
: path.basename(derivedProjectPath) || "unknown";
219+
: PlatformPaths.basename(derivedProjectPath) || "unknown";
219220
} else {
220221
// In-place mode: workspace is a standalone directory
221222
// Store the workspace path directly by setting projectPath === name
@@ -224,7 +225,7 @@ export class AgentSession {
224225
derivedProjectName =
225226
projectName && projectName.trim().length > 0
226227
? projectName.trim()
227-
: path.basename(normalizedWorkspacePath) || "unknown";
228+
: PlatformPaths.basename(normalizedWorkspacePath) || "unknown";
228229
}
229230

230231
const metadata: WorkspaceMetadata = {

src/services/streamManager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventEmitter } from "events";
22
import * as path from "path";
3+
import { PlatformPaths } from "@/utils/paths";
34
import {
45
streamText,
56
stepCountIs,
@@ -954,7 +955,7 @@ export class StreamManager extends EventEmitter {
954955
if (streamInfo.runtimeTempDir) {
955956
// Use parent directory as cwd for safety - if runtimeTempDir is malformed,
956957
// we won't accidentally run rm -rf from root
957-
const tempDirBasename = path.basename(streamInfo.runtimeTempDir);
958+
const tempDirBasename = PlatformPaths.basename(streamInfo.runtimeTempDir);
958959
const tempDirParent = path.dirname(streamInfo.runtimeTempDir);
959960
void streamInfo.runtime
960961
.exec(`rm -rf "${tempDirBasename}"`, {

src/utils/pathUtils.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as fs from "fs/promises";
2-
import * as os from "os";
32
import * as path from "path";
3+
import { PlatformPaths } from "./paths";
44

55
/**
66
* Result of path validation
@@ -23,19 +23,7 @@ export interface PathValidationResult {
2323
* expandTilde("/absolute/path") // => "/absolute/path"
2424
*/
2525
export function expandTilde(inputPath: string): string {
26-
if (!inputPath) {
27-
return inputPath;
28-
}
29-
30-
if (inputPath === "~") {
31-
return os.homedir();
32-
}
33-
34-
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
35-
return path.join(os.homedir(), inputPath.slice(2));
36-
}
37-
38-
return inputPath;
26+
return PlatformPaths.expandHome(inputPath);
3927
}
4028

4129
/**

src/utils/paths.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { PlatformPaths } from "./paths";
3+
import * as os from "os";
4+
import * as path from "path";
5+
6+
describe("PlatformPaths", () => {
7+
describe("basename", () => {
8+
test("extracts basename from path using current platform", () => {
9+
expect(PlatformPaths.basename("/home/user/project")).toBe("project");
10+
expect(PlatformPaths.basename("/home/user/project/file.txt")).toBe("file.txt");
11+
});
12+
13+
test("handles edge cases", () => {
14+
expect(PlatformPaths.basename("")).toBe("");
15+
expect(PlatformPaths.basename("project")).toBe("project");
16+
});
17+
});
18+
19+
describe("parse", () => {
20+
test("parses absolute path on current platform", () => {
21+
const testPath = path.join("/", "home", "user", "projects", "cmux");
22+
const result = PlatformPaths.parse(testPath);
23+
expect(result.segments).toContain("home");
24+
expect(result.segments).toContain("user");
25+
expect(result.segments).toContain("projects");
26+
expect(result.basename).toBe("cmux");
27+
});
28+
29+
test("parses relative path", () => {
30+
const result = PlatformPaths.parse("src/utils/paths.ts");
31+
expect(result.root).toBe("");
32+
expect(result.basename).toBe("paths.ts");
33+
});
34+
35+
test("handles edge cases", () => {
36+
expect(PlatformPaths.parse("")).toEqual({ root: "", segments: [], basename: "" });
37+
expect(PlatformPaths.parse("file.txt").basename).toBe("file.txt");
38+
});
39+
});
40+
41+
describe("abbreviate", () => {
42+
test("abbreviates path", () => {
43+
const testPath = path.join("/", "home", "user", "Projects", "coder", "cmux");
44+
const result = PlatformPaths.abbreviate(testPath);
45+
46+
// Should end with the full basename
47+
expect(result.endsWith("cmux")).toBe(true);
48+
49+
// Should be shorter than original (segments abbreviated)
50+
expect(result.length).toBeLessThan(testPath.length);
51+
});
52+
53+
test("handles short paths", () => {
54+
const testPath = path.join("/", "home");
55+
const result = PlatformPaths.abbreviate(testPath);
56+
// Short paths should not be abbreviated much
57+
expect(result).toContain("home");
58+
});
59+
60+
test("handles empty input", () => {
61+
expect(PlatformPaths.abbreviate("")).toBe("");
62+
});
63+
});
64+
65+
describe("splitAbbreviated", () => {
66+
test("splits abbreviated path", () => {
67+
const testPath = path.join("/", "h", "u", "P", "c", "cmux");
68+
const result = PlatformPaths.splitAbbreviated(testPath);
69+
expect(result.basename).toBe("cmux");
70+
expect(result.dirPath.endsWith(path.sep)).toBe(true);
71+
});
72+
73+
test("handles path without directory", () => {
74+
const result = PlatformPaths.splitAbbreviated("file.txt");
75+
expect(result.dirPath).toBe("");
76+
expect(result.basename).toBe("file.txt");
77+
});
78+
});
79+
80+
describe("formatHome", () => {
81+
test("replaces home directory with tilde on Unix", () => {
82+
const home = os.homedir();
83+
const testPath = path.join(home, "projects", "cmux");
84+
const result = PlatformPaths.formatHome(testPath);
85+
86+
// On Unix-like systems, should use tilde
87+
if (process.platform !== "win32") {
88+
expect(result).toBe("~/projects/cmux");
89+
} else {
90+
// On Windows, should keep full path
91+
expect(result).toContain(home);
92+
}
93+
});
94+
95+
test("leaves non-home paths unchanged", () => {
96+
const result = PlatformPaths.formatHome("/tmp/test");
97+
expect(result).toBe("/tmp/test");
98+
});
99+
});
100+
101+
describe("expandHome", () => {
102+
test("expands tilde to home directory", () => {
103+
const home = os.homedir();
104+
expect(PlatformPaths.expandHome("~")).toBe(home);
105+
});
106+
107+
test("expands tilde with path", () => {
108+
const home = os.homedir();
109+
const sep = path.sep;
110+
const result = PlatformPaths.expandHome(`~${sep}projects${sep}cmux`);
111+
expect(result).toBe(path.join(home, "projects", "cmux"));
112+
});
113+
114+
test("leaves absolute paths unchanged", () => {
115+
const testPath = path.join("/", "home", "user", "project");
116+
expect(PlatformPaths.expandHome(testPath)).toBe(testPath);
117+
});
118+
119+
test("handles empty input", () => {
120+
expect(PlatformPaths.expandHome("")).toBe("");
121+
});
122+
});
123+
124+
describe("getProjectName", () => {
125+
test("extracts project name from path", () => {
126+
const testPath = path.join("/", "home", "user", "projects", "cmux");
127+
expect(PlatformPaths.getProjectName(testPath)).toBe("cmux");
128+
});
129+
130+
test("handles relative paths", () => {
131+
expect(PlatformPaths.getProjectName("projects/cmux")).toBe("cmux");
132+
});
133+
134+
test("returns 'unknown' for empty path", () => {
135+
expect(PlatformPaths.getProjectName("")).toBe("unknown");
136+
});
137+
});
138+
139+
describe("separator", () => {
140+
test("returns correct separator for platform", () => {
141+
const sep = PlatformPaths.separator;
142+
// Should match the current platform's separator
143+
expect(sep).toBe(path.sep);
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)