diff --git a/.github/actions/setup-mux/action.yml b/.github/actions/setup-mux/action.yml index 6a248abfe..97f2b42e4 100644 --- a/.github/actions/setup-mux/action.yml +++ b/.github/actions/setup-mux/action.yml @@ -62,3 +62,14 @@ runs: sudo apt-get install -y --no-install-recommends imagemagick fi convert --version | head -1 + - name: Install ImageMagick (Windows) + if: inputs.install-imagemagick == 'true' && runner.os == 'Windows' + shell: powershell + run: | + if (Get-Command magick -ErrorAction SilentlyContinue) { + Write-Host "✅ ImageMagick already available" + } else { + Write-Host "📦 Installing ImageMagick..." + choco install -y imagemagick + } + magick --version | Select-Object -First 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b59c0e9dd..a7f4e004e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,3 +79,34 @@ jobs: gh release upload ${{ github.event.release.tag_name }} \ vscode/cmux-*.vsix \ --clobber + + build-windows: + name: Build and Release Windows + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for git describe to find tags + + - uses: ./.github/actions/setup-cmux + with: + install-imagemagick: true + + - name: Install GNU Make (for build) + run: choco install -y make + + - name: Verify tools + shell: bash + run: | + make --version + bun --version + magick --version | head -1 + + - name: Build application + run: bun run build + + - name: Package and publish for Windows (.exe) + run: bun x electron-builder --win --publish always + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index 4319dfd13..bf465c89c 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,15 @@ # Branches reduce reproducibility - builds should fail fast with clear errors # if dependencies are missing, not silently fall back to different behavior. +# Use PATH-resolved bash on Windows to avoid hardcoded /usr/bin/bash which doesn't +# exist in Chocolatey's make environment or on GitHub Actions windows-latest. +ifeq ($(OS),Windows_NT) +SHELL := bash +else +SHELL := /bin/bash +endif +.SHELLFLAGS := -eu -o pipefail -c + # Enable parallel execution by default (only if user didn't specify -j) ifeq (,$(filter -j%,$(MAKEFLAGS))) MAKEFLAGS += -j @@ -84,10 +93,6 @@ node_modules/.installed: package.json bun.lock # Legacy target for backwards compatibility ensure-deps: node_modules/.installed - - - - ## Help help: ## Show this help message @echo 'Usage: make [target]' @@ -96,11 +101,34 @@ help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' ## Development +ifeq ($(OS),Windows_NT) +dev: node_modules/.installed build-main ## Start development server (Vite + nodemon watcher for Windows compatibility) + @echo "Starting dev mode (2 watchers: nodemon for main process, vite for renderer)..." + # On Windows, use npm run because bunx doesn't correctly pass arguments to concurrently + # https://github.com/oven-sh/bun/issues/18275 + @NODE_OPTIONS="--max-old-space-size=4096" npm x concurrently -k --raw \ + "bun x nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \ + "vite" +else dev: node_modules/.installed build-main ## Start development server (Vite + tsgo watcher for 10x faster type checking) @bun x concurrently -k \ "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ "vite" +endif +ifeq ($(OS),Windows_NT) +dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access + @echo "Starting dev-server..." + @echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)" + @echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)" + @echo "" + @echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0" + @# On Windows, use npm run because bunx doesn't correctly pass arguments + @npmx concurrently -k \ + "npmx nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \ + "npmx nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec \"node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)\"" \ + "$(SHELL) -lc \"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite\"" +else dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access @echo "Starting dev-server..." @echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)" @@ -111,6 +139,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ "bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'NODE_ENV=development node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \ "MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite" +endif @@ -167,16 +196,16 @@ MAGICK_CMD := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev build/icon.png: docs/img/logo.webp @echo "Generating Linux icon..." @mkdir -p build - @$(MAGICK_CMD) docs/img/logo.webp -resize 512x512 build/icon.png + @"$(MAGICK_CMD)" docs/img/logo.webp -resize 512x512 build/icon.png build/icon.icns: docs/img/logo.webp @echo "Generating macOS icon..." @mkdir -p build/icon.iconset @for size in 16 32 64 128 256 512; do \ - $(MAGICK_CMD) docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \ + "$(MAGICK_CMD)" docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \ if [ $$size -le 256 ]; then \ double=$$((size * 2)); \ - $(MAGICK_CMD) docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \ + "$(MAGICK_CMD)" docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \ fi; \ done @iconutil -c icns build/icon.iconset -o build/icon.icns @@ -191,10 +220,18 @@ lint: node_modules/.installed ## Run ESLint (typecheck runs in separate target) lint-fix: node_modules/.installed ## Run linter with --fix @./scripts/lint.sh --fix +ifeq ($(OS),Windows_NT) typecheck: node_modules/.installed src/version.ts ## Run TypeScript type checking (uses tsgo for 10x speedup) + @# On Windows, use npm run because bun x doesn't correctly pass arguments + @npmx concurrently -g \ + "$(TSGO) --noEmit" \ + "$(TSGO) --noEmit -p tsconfig.main.json" +else +typecheck: node_modules/.installed src/version.ts @bun x concurrently -g \ "$(TSGO) --noEmit" \ "$(TSGO) --noEmit -p tsconfig.main.json" +endif check-deadcode: node_modules/.installed ## Check for potential dead code (manual only, not in static-check) @echo "Checking for potential dead code with ts-prune..." diff --git a/package.json b/package.json index c36d44eb4..7278ad39b 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,9 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "win": { - "target": "nsis" + "target": "nsis", + "icon": "build/icon.png", + "artifactName": "${productName}-${version}-${arch}.${ext}" } } } diff --git a/public/service-worker.js b/public/service-worker.js index 7d8eba2d1..961d9f026 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -51,7 +51,20 @@ self.addEventListener("fetch", (event) => { }) .catch(() => { // If network fails, try cache - return caches.match(event.request); + return caches.match(event.request).then((cachedResponse) => { + // If cache has it, return it; otherwise return a proper error response + if (cachedResponse) { + return cachedResponse; + } + // Return a proper Response object for failed requests + return new Response("Network error and no cached version available", { + status: 503, + statusText: "Service Unavailable", + headers: new Headers({ + "Content-Type": "text/plain", + }), + }); + }); }) ); }); diff --git a/scripts/build-main-watch.js b/scripts/build-main-watch.js new file mode 100644 index 000000000..3b57fb8bd --- /dev/null +++ b/scripts/build-main-watch.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Build script for main process in watch mode + * Used by nodemon - ignores file arguments passed by nodemon + */ + +const { execSync } = require('child_process'); +const path = require('path'); + +const rootDir = path.join(__dirname, '..'); +const tsgoPath = path.join(rootDir, 'node_modules/@typescript/native-preview/bin/tsgo.js'); +const tscAliasPath = path.join(rootDir, 'node_modules/tsc-alias/dist/bin/index.js'); + +try { + console.log('Building main process...'); + + // Run tsgo + execSync(`node "${tsgoPath}" -p tsconfig.main.json`, { + cwd: rootDir, + stdio: 'inherit', + env: { ...process.env, NODE_ENV: 'development' } + }); + + // Run tsc-alias + execSync(`node "${tscAliasPath}" -p tsconfig.main.json`, { + cwd: rootDir, + stdio: 'inherit', + env: { ...process.env, NODE_ENV: 'development' } + }); + + console.log('✓ Main process build complete'); +} catch (error) { + console.error('Build failed:', error.message); + process.exit(1); +} + diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index e2f4a22de..d0b54adcd 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -9,7 +9,7 @@ import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend"; import { useDrag, useDrop, useDragLayer } from "react-dnd"; import { sortProjectsByOrder, reorderProjects, normalizeOrder } from "@/utils/projectOrdering"; import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; -import { abbreviatePath, splitAbbreviatedPath } from "@/utils/ui/pathAbbreviation"; +import { PlatformPaths } from "@/utils/paths"; import { partitionWorkspacesByAge, formatOldWorkspaceThreshold, @@ -131,8 +131,8 @@ const ProjectDragLayer: React.FC = () => { if (!isDragging || !currentOffset || !item?.projectPath) return null; - const abbrevPath = abbreviatePath(item.projectPath); - const { dirPath, basename } = splitAbbreviatedPath(abbrevPath); + const abbrevPath = PlatformPaths.abbreviate(item.projectPath); + const { dirPath, basename } = PlatformPaths.splitAbbreviated(abbrevPath); return (
@@ -238,7 +238,7 @@ const ProjectSidebarInner: React.FC = ({ if (!path || typeof path !== "string") { return "Unknown"; } - return path.split("/").pop() ?? path.split("\\").pop() ?? path; + return PlatformPaths.getProjectName(path); }; const toggleProject = (projectPath: string) => { @@ -498,8 +498,9 @@ const ProjectSidebarInner: React.FC = ({
{(() => { - const abbrevPath = abbreviatePath(projectPath); - const { dirPath, basename } = splitAbbreviatedPath(abbrevPath); + const abbrevPath = PlatformPaths.abbreviate(projectPath); + const { dirPath, basename } = + PlatformPaths.splitAbbreviated(abbrevPath); return ( <> {dirPath} diff --git a/src/config.ts b/src/config.ts index c16075294..9a1c3dcad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ import type { Secret, SecretsConfig } from "./types/secrets"; import type { Workspace, ProjectConfig, ProjectsConfig } from "./types/project"; import { DEFAULT_RUNTIME_CONFIG } from "./constants/workspace"; import { getMuxHome } from "./constants/paths"; +import { PlatformPaths } from "./utils/paths"; // Re-export project types from dedicated types file (for preload usage) export type { Workspace, ProjectConfig, ProjectsConfig }; @@ -96,7 +97,7 @@ export class Config { } private getProjectName(projectPath: string): string { - return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; + return PlatformPaths.getProjectName(projectPath); } /** @@ -120,8 +121,7 @@ export class Config { */ generateLegacyId(projectPath: string, workspacePath: string): string { const projectBasename = this.getProjectName(projectPath); - const workspaceBasename = - workspacePath.split("/").pop() ?? workspacePath.split("\\").pop() ?? "unknown"; + const workspaceBasename = PlatformPaths.basename(workspacePath); return `${projectBasename}-${workspaceBasename}`; } diff --git a/src/debug/agentSessionCli.ts b/src/debug/agentSessionCli.ts index 4adbadf91..0b2ba6580 100644 --- a/src/debug/agentSessionCli.ts +++ b/src/debug/agentSessionCli.ts @@ -3,6 +3,7 @@ import assert from "@/utils/assert"; import * as fs from "fs/promises"; import * as path from "path"; +import { PlatformPaths } from "../utils/paths"; import { parseArgs } from "util"; import { Config } from "@/config"; import { HistoryService } from "@/services/historyService"; @@ -168,8 +169,8 @@ async function main(): Promise { const projectPathRaw = values["project-path"]; const projectName = typeof projectPathRaw === "string" && projectPathRaw.trim().length > 0 - ? path.basename(path.resolve(projectPathRaw.trim())) - : path.basename(path.dirname(workspacePath)) || "unknown"; + ? PlatformPaths.basename(path.resolve(projectPathRaw.trim())) + : PlatformPaths.basename(path.dirname(workspacePath)) || "unknown"; const messageArg = values.message && values.message.trim().length > 0 ? values.message : undefined; diff --git a/src/debug/list-workspaces.ts b/src/debug/list-workspaces.ts index fc713343b..f2bf59af9 100644 --- a/src/debug/list-workspaces.ts +++ b/src/debug/list-workspaces.ts @@ -1,5 +1,5 @@ import { defaultConfig } from "@/config"; -import * as path from "path"; +import { PlatformPaths } from "../utils/paths"; import * as fs from "fs"; import { getMuxSessionsDir } from "@/constants/paths"; @@ -10,13 +10,13 @@ export function listWorkspacesCommand() { console.log("Projects in config:", config.projects.size); for (const [projectPath, project] of config.projects) { - const projectName = path.basename(projectPath); + const projectName = PlatformPaths.basename(projectPath); console.log(`\nProject: ${projectName}`); console.log(` Path: ${projectPath}`); console.log(` Workspaces: ${project.workspaces.length}`); for (const workspace of project.workspaces) { - const dirName = path.basename(workspace.path); + const dirName = PlatformPaths.basename(workspace.path); console.log(` - Directory: ${dirName}`); if (workspace.id) { console.log(` ID: ${workspace.id}`); diff --git a/src/main.tsx b/src/main.tsx index e37858de6..e655cd1ae 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -41,14 +41,18 @@ ReactDOM.createRoot(document.getElementById("root")!).render( // Register service worker for PWA support if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - navigator.serviceWorker - .register("/service-worker.js") - .then((registration) => { - console.log("Service Worker registered:", registration); - }) - .catch((error) => { - console.log("Service Worker registration failed:", error); - }); - }); + const isHttpProtocol = + window.location.protocol === "http:" || window.location.protocol === "https:"; + if (isHttpProtocol) { + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/service-worker.js") + .then((registration) => { + console.log("Service Worker registered:", registration); + }) + .catch((error) => { + console.log("Service Worker registration failed:", error); + }); + }); + } } diff --git a/src/runtime/LocalRuntime.ts b/src/runtime/LocalRuntime.ts index 3087e24eb..784f2e122 100644 --- a/src/runtime/LocalRuntime.ts +++ b/src/runtime/LocalRuntime.ts @@ -18,6 +18,7 @@ import type { } from "./Runtime"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { NON_INTERACTIVE_ENV_VARS } from "../constants/env"; +import { getBashPath } from "../utils/main/bashPath"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "../constants/exitCodes"; import { listLocalBranches } from "../git"; import { @@ -61,11 +62,13 @@ export class LocalRuntime implements Runtime { ); } - // If niceness is specified, spawn nice directly to avoid escaping issues - const spawnCommand = options.niceness !== undefined ? "nice" : "bash"; - const bashPath = "bash"; + // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues + // Windows doesn't have nice command, so just spawn bash directly + const isWindows = process.platform === "win32"; + const bashPath = getBashPath(); + const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; const spawnArgs = - options.niceness !== undefined + options.niceness !== undefined && !isWindows ? ["-n", options.niceness.toString(), bashPath, "-c", command] : ["-c", command]; @@ -417,7 +420,8 @@ export class LocalRuntime implements Runtime { const loggers = createLineBufferedLoggers(initLogger); return new Promise((resolve) => { - const proc = spawn("bash", ["-c", `"${hookPath}"`], { + const bashPath = getBashPath(); + const proc = spawn(bashPath, ["-c", `"${hookPath}"`], { cwd: workspacePath, stdio: ["ignore", "pipe", "pipe"], env: { diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index d85a6d03e..ed46e4ed6 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -1,7 +1,6 @@ import { spawn } from "child_process"; import { Readable, Writable } from "stream"; import * as path from "path"; -import { Shescape } from "shescape"; import type { Runtime, ExecOptions, @@ -25,12 +24,20 @@ import { getProjectName } from "../utils/runtime/helpers"; import { getErrorMessage } from "../utils/errors"; import { execAsync, DisposableProcess } from "../utils/disposableExec"; import { getControlPath } from "./sshConnectionPool"; +import { getBashPath } from "../utils/main/bashPath"; /** - * Shescape instance for bash shell escaping. + * Shell-escape helper for remote bash. * Reused across all SSH runtime operations for performance. */ -const shescape = new Shescape({ shell: "bash" }); +const shescape = { + quote(value: unknown): string { + const s = String(value); + if (s.length === 0) return "''"; + // Use POSIX-safe pattern to embed single quotes within single-quoted strings + return "'" + s.replace(/'/g, "'\"'\"'") + "'"; + }, +}; /** * SSH Runtime Configuration @@ -574,7 +581,8 @@ export class SSHRuntime implements Runtime { const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`; log.debug(`Creating bundle: ${command}`); - const proc = spawn("bash", ["-c", command]); + const bashPath = getBashPath(); + const proc = spawn(bashPath, ["-c", command]); const cleanup = streamProcessToLogger(proc, initLogger, { logStdout: false, diff --git a/src/runtime/tildeExpansion.ts b/src/runtime/tildeExpansion.ts index e5084b6ca..63c25b9b7 100644 --- a/src/runtime/tildeExpansion.ts +++ b/src/runtime/tildeExpansion.ts @@ -8,8 +8,7 @@ * For local paths, tildes should be expanded to actual file system paths. */ -import * as os from "os"; -import * as path from "path"; +import { PlatformPaths } from "../utils/paths.main"; /** * Expand tilde to actual home directory path for local file system operations. @@ -28,13 +27,7 @@ import * as path from "path"; * expandTilde("/abs/path") // => "/abs/path" */ export function expandTilde(filePath: string): string { - if (filePath === "~") { - return os.homedir(); - } else if (filePath.startsWith("~/")) { - return path.join(os.homedir(), filePath.slice(2)); - } else { - return filePath; - } + return PlatformPaths.expandHome(filePath); } /** diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index c5bfe08a8..d394527f6 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -1,6 +1,7 @@ import assert from "@/utils/assert"; import { EventEmitter } from "events"; import * as path from "path"; +import { PlatformPaths } from "@/utils/paths"; import { createMuxMessage } from "@/types/message"; import type { Config } from "@/config"; import type { AIService } from "@/services/aiService"; @@ -211,11 +212,11 @@ export class AgentSession { if (isUnderSrcBaseDir) { // Standard worktree mode: workspace is under ~/.cmux/src/project/branch derivedProjectPath = path.dirname(normalizedWorkspacePath); - workspaceName = path.basename(normalizedWorkspacePath); + workspaceName = PlatformPaths.basename(normalizedWorkspacePath); derivedProjectName = projectName && projectName.trim().length > 0 ? projectName.trim() - : path.basename(derivedProjectPath) || "unknown"; + : PlatformPaths.basename(derivedProjectPath) || "unknown"; } else { // In-place mode: workspace is a standalone directory // Store the workspace path directly by setting projectPath === name @@ -224,7 +225,7 @@ export class AgentSession { derivedProjectName = projectName && projectName.trim().length > 0 ? projectName.trim() - : path.basename(normalizedWorkspacePath) || "unknown"; + : PlatformPaths.basename(normalizedWorkspacePath) || "unknown"; } const metadata: WorkspaceMetadata = { diff --git a/src/services/bashExecutionService.ts b/src/services/bashExecutionService.ts index 623165f03..a03ea130d 100644 --- a/src/services/bashExecutionService.ts +++ b/src/services/bashExecutionService.ts @@ -1,6 +1,7 @@ import { spawn } from "child_process"; import type { ChildProcess } from "child_process"; import { log } from "./log"; +import { getBashPath } from "../utils/main/bashPath"; /** * Configuration for bash execution @@ -120,10 +121,13 @@ export class BashExecutionService { `BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}` ); - const spawnCommand = config.niceness !== undefined ? "nice" : "bash"; + // Windows doesn't have nice command, so just spawn bash directly + const isWindows = process.platform === "win32"; + const bashPath = getBashPath(); + const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath; const spawnArgs = - config.niceness !== undefined - ? ["-n", config.niceness.toString(), "bash", "-c", script] + config.niceness !== undefined && !isWindows + ? ["-n", config.niceness.toString(), bashPath, "-c", script] : ["-c", script]; const child = spawn(spawnCommand, spawnArgs, { diff --git a/src/services/streamManager.ts b/src/services/streamManager.ts index 8ec0ee4ec..cb0d4d8a5 100644 --- a/src/services/streamManager.ts +++ b/src/services/streamManager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "events"; import * as path from "path"; +import { PlatformPaths } from "@/utils/paths"; import { streamText, stepCountIs, @@ -954,7 +955,7 @@ export class StreamManager extends EventEmitter { if (streamInfo.runtimeTempDir) { // Use parent directory as cwd for safety - if runtimeTempDir is malformed, // we won't accidentally run rm -rf from root - const tempDirBasename = path.basename(streamInfo.runtimeTempDir); + const tempDirBasename = PlatformPaths.basename(streamInfo.runtimeTempDir); const tempDirParent = path.dirname(streamInfo.runtimeTempDir); void streamInfo.runtime .exec(`rm -rf "${tempDirBasename}"`, { diff --git a/src/utils/main/bashPath.ts b/src/utils/main/bashPath.ts new file mode 100644 index 000000000..052de9e9b --- /dev/null +++ b/src/utils/main/bashPath.ts @@ -0,0 +1,116 @@ +/** + * Platform-specific bash path resolution + * + * On Unix/Linux/macOS, bash is in PATH by default. + * On Windows, bash comes from Git Bash and needs to be located. + */ + +import { execSync } from "child_process"; +import { existsSync } from "fs"; +import path from "path"; + +let cachedBashPath: string | null = null; + +/** + * Find bash executable path on Windows + * Checks common Git Bash installation locations + */ +function findWindowsBash(): string | null { + // Common Git Bash installation paths + const commonPaths = [ + // Git for Windows default paths + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", + // User-local Git installation + path.join(process.env.LOCALAPPDATA ?? "", "Programs", "Git", "bin", "bash.exe"), + // Portable Git + path.join(process.env.USERPROFILE ?? "", "scoop", "apps", "git", "current", "bin", "bash.exe"), + // Chocolatey installation + "C:\\tools\\git\\bin\\bash.exe", + ]; + + // Check if bash is in PATH first + try { + const result = execSync("where bash", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }); + const firstPath = result.split("\n")[0].trim(); + if (firstPath && existsSync(firstPath)) { + return firstPath; + } + } catch { + // Not in PATH, continue to check common locations + } + + // Check common installation paths + for (const bashPath of commonPaths) { + if (existsSync(bashPath)) { + return bashPath; + } + } + + // Also check if Git is in PATH and derive bash path from it + try { + const gitPath = execSync("where git", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }); + const firstGitPath = gitPath.split("\n")[0].trim(); + if (firstGitPath) { + // Git is usually in Git/cmd/git.exe, bash is in Git/bin/bash.exe + const gitDir = path.dirname(path.dirname(firstGitPath)); + const bashPath = path.join(gitDir, "bin", "bash.exe"); + if (existsSync(bashPath)) { + return bashPath; + } + // Also try usr/bin/bash.exe (newer Git for Windows structure) + const usrBashPath = path.join(gitDir, "usr", "bin", "bash.exe"); + if (existsSync(usrBashPath)) { + return usrBashPath; + } + } + } catch { + // Git not in PATH + } + + return null; +} + +/** + * Get the bash executable path for the current platform + * + * @returns Path to bash executable. On Unix/macOS returns "bash", + * on Windows returns full path to bash.exe if found. + * @throws Error if bash cannot be found on Windows + */ +export function getBashPath(): string { + // On Unix/Linux/macOS, bash is in PATH + if (process.platform !== "win32") { + return "bash"; + } + + // Use cached path if available + if (cachedBashPath !== null) { + return cachedBashPath; + } + + // Find bash on Windows + const bashPath = findWindowsBash(); + if (!bashPath) { + throw new Error( + "Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win" + ); + } + + cachedBashPath = bashPath; + return bashPath; +} + +/** + * Check if bash is available on the system + * + * @returns true if bash is available, false otherwise + */ +export function isBashAvailable(): boolean { + try { + getBashPath(); + return true; + } catch { + return false; + } +} diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index 4514280d6..85912a823 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -1,6 +1,6 @@ import * as fs from "fs/promises"; -import * as os from "os"; import * as path from "path"; +import { PlatformPaths } from "./paths.main"; /** * Result of path validation @@ -23,19 +23,7 @@ export interface PathValidationResult { * expandTilde("/absolute/path") // => "/absolute/path" */ export function expandTilde(inputPath: string): string { - if (!inputPath) { - return inputPath; - } - - if (inputPath === "~") { - return os.homedir(); - } - - if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { - return path.join(os.homedir(), inputPath.slice(2)); - } - - return inputPath; + return PlatformPaths.expandHome(inputPath); } /** diff --git a/src/utils/paths.main.ts b/src/utils/paths.main.ts new file mode 100644 index 000000000..eb81dacf7 --- /dev/null +++ b/src/utils/paths.main.ts @@ -0,0 +1,237 @@ +/** + * Platform-aware path utilities for main process (Node) only. + * Safe to use Node globals like process and environment variables. + * + * NOTE: Renderer should import from './paths' (renderer-safe subset) + * and use IPC for any operations that require environment access. + */ + +import { platform, env } from "node:process"; + +export interface PathComponents { + root: string; // "/" on Unix, "C:\\" on Windows, "" for relative paths + segments: string[]; // Directory segments (excluding basename) + basename: string; // Final path component +} + +/** + * Determine if current platform is Windows (main process) + */ +function isWindowsPlatform(): boolean { + return platform === "win32"; +} + +function getSeparator(): string { + return isWindowsPlatform() ? "\\" : "/"; +} + +function getHomeDir(): string { + if (isWindowsPlatform()) { + return env.USERPROFILE ?? ""; + } + + return env.HOME ?? ""; +} + +/** + * OS-aware path utilities that handle Windows and Unix paths correctly. + * Main-process version includes environment-aware helpers. + */ +export class PlatformPaths { + /** + * Get the appropriate path separator for the current platform + */ + static get separator(): string { + return getSeparator(); + } + + /** + * Extract basename from path (OS-aware) + */ + static basename(filePath: string): string { + if (!filePath || typeof filePath !== "string") { + return filePath; + } + + const lastSlash = isWindowsPlatform() + ? Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")) + : filePath.lastIndexOf("/"); + if (lastSlash === -1) { + return filePath; + } + return filePath.slice(lastSlash + 1); + } + + /** + * Split path into components (OS-aware) + */ + static parse(filePath: string): PathComponents { + if (!filePath || typeof filePath !== "string") { + return { root: "", segments: [], basename: filePath }; + } + + const original = filePath; + let root = ""; + let dir = ""; + let base = ""; + + // Determine basename and directory + const lastSlash = isWindowsPlatform() + ? Math.max(original.lastIndexOf("/"), original.lastIndexOf("\\")) + : original.lastIndexOf("/"); + if (lastSlash === -1) { + base = original; + dir = ""; + } else { + base = original.slice(lastSlash + 1); + dir = original.slice(0, lastSlash); + } + + // Determine root + if (isWindowsPlatform()) { + const driveMatch = /^[A-Za-z]:[\\/]/.exec(original); + if (driveMatch) { + root = driveMatch[0]; + // Ensure dir does not include root + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } else if (original.startsWith("\\\\")) { + // UNC paths - treat leading double-backslash as root + root = "\\\\"; + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } + // Also treat Unix-style absolute paths as absolute even on Windows + if (!root && original.startsWith("/")) { + root = "/"; + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } + } else if (original.startsWith("/")) { + root = "/"; + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } + + const separatorRegex = isWindowsPlatform() ? /[\\/]+/ : /\/+/; + const segments = dir ? dir.split(separatorRegex).filter(Boolean) : []; + + return { + root, + segments, + basename: base, + }; + } + + /** + * Format path for display with fish-style abbreviation (OS-aware) + * Abbreviates all directory components except the last one to their first letter + */ + static abbreviate(filePath: string): string { + if (!filePath || typeof filePath !== "string") { + return filePath; + } + + const { root, segments, basename } = this.parse(filePath); + + // Abbreviate all segments to first character + const abbreviated = segments.map((seg) => (seg.length > 0 ? seg[0] : seg)); + + // Reconstruct path - handle root separately to avoid double separator + if (!root && abbreviated.length === 0) { + return basename; + } + + const sep = isWindowsPlatform() ? (filePath.includes("\\") ? "\\" : "/") : "/"; + const joined = [...abbreviated, basename].filter(Boolean).join(sep); + if (!root) { + return joined; + } + const rootEndsWithSep = root.endsWith("\\") || root.endsWith("/"); + return rootEndsWithSep ? root + joined : root + sep + joined; + } + + /** + * Split an abbreviated path into directory path and basename + */ + static splitAbbreviated(filePath: string): { dirPath: string; basename: string } { + if (!filePath || typeof filePath !== "string") { + return { dirPath: "", basename: filePath }; + } + + const sep = isWindowsPlatform() ? (filePath.includes("\\") ? "\\" : "/") : "/"; + const lastSlash = filePath.lastIndexOf(sep); + if (lastSlash === -1) { + return { dirPath: "", basename: filePath }; + } + return { + dirPath: filePath.slice(0, lastSlash + 1), + basename: filePath.slice(lastSlash + 1), + }; + } + + /** + * Format home directory path for display (shows ~ on Unix, full path on Windows) + */ + static formatHome(filePath: string): string { + if (!filePath || typeof filePath !== "string") { + return filePath; + } + + const home = getHomeDir(); + if (!home) { + return filePath; + } + + // Replace home with tilde on all platforms for display purposes + if (filePath.startsWith(home)) { + return filePath.replace(home, "~"); + } + + return filePath; + } + + /** + * Expand user home in path (cross-platform) + * Handles ~ on Unix and %USERPROFILE% on Windows + */ + static expandHome(filePath: string): string { + if (!filePath || typeof filePath !== "string") { + return filePath; + } + + if (filePath === "~") { + return getHomeDir() || filePath; + } + + // Handle Unix-style ~/path + if (filePath.startsWith("~/") || filePath.startsWith("~\\")) { + const home = getHomeDir(); + if (!home) return filePath; + const sep = getSeparator(); + const rest = filePath.slice(2); + return home + (rest ? sep + rest.replace(/[\\/]+/g, sep) : ""); + } + + // Handle Windows %USERPROFILE% environment variable + if (isWindowsPlatform() && filePath.includes("%USERPROFILE%")) { + const home = getHomeDir(); + if (!home) return filePath; + return filePath.replace(/%USERPROFILE%/g, home); + } + + return filePath; + } + + /** + * Get project name from path (OS-aware) + * Extracts the final directory name from a project path + */ + static getProjectName(projectPath: string): string { + return this.basename(projectPath) || "unknown"; + } +} diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts new file mode 100644 index 000000000..43c77402e --- /dev/null +++ b/src/utils/paths.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect } from "bun:test"; +import { PlatformPaths } from "./paths.main"; +import * as os from "os"; +import * as path from "path"; + +describe("PlatformPaths", () => { + describe("basename", () => { + test("extracts basename from path using current platform", () => { + expect(PlatformPaths.basename("/home/user/project")).toBe("project"); + expect(PlatformPaths.basename("/home/user/project/file.txt")).toBe("file.txt"); + }); + + test("handles edge cases", () => { + expect(PlatformPaths.basename("")).toBe(""); + expect(PlatformPaths.basename("project")).toBe("project"); + }); + }); + + describe("parse", () => { + test("parses absolute path on current platform", () => { + const testPath = path.join("/", "home", "user", "projects", "cmux"); + const result = PlatformPaths.parse(testPath); + expect(result.segments).toContain("home"); + expect(result.segments).toContain("user"); + expect(result.segments).toContain("projects"); + expect(result.basename).toBe("cmux"); + }); + + test("parses relative path", () => { + const result = PlatformPaths.parse("src/utils/paths.ts"); + expect(result.root).toBe(""); + expect(result.basename).toBe("paths.ts"); + }); + + test("handles edge cases", () => { + expect(PlatformPaths.parse("")).toEqual({ root: "", segments: [], basename: "" }); + expect(PlatformPaths.parse("file.txt").basename).toBe("file.txt"); + }); + }); + + describe("abbreviate", () => { + test("abbreviates path", () => { + const testPath = path.join("/", "home", "user", "Projects", "coder", "cmux"); + const result = PlatformPaths.abbreviate(testPath); + + // Should end with the full basename + expect(result.endsWith("cmux")).toBe(true); + + // Should be shorter than original (segments abbreviated) + expect(result.length).toBeLessThan(testPath.length); + }); + + test("handles short paths", () => { + const testPath = path.join("/", "home"); + const result = PlatformPaths.abbreviate(testPath); + // Short paths should not be abbreviated much + expect(result).toContain("home"); + }); + + test("handles empty input", () => { + expect(PlatformPaths.abbreviate("")).toBe(""); + }); + }); + + describe("splitAbbreviated", () => { + test("splits abbreviated path", () => { + const testPath = path.join("/", "h", "u", "P", "c", "cmux"); + const result = PlatformPaths.splitAbbreviated(testPath); + expect(result.basename).toBe("cmux"); + expect(result.dirPath.endsWith(path.sep)).toBe(true); + }); + + test("handles path without directory", () => { + const result = PlatformPaths.splitAbbreviated("file.txt"); + expect(result.dirPath).toBe(""); + expect(result.basename).toBe("file.txt"); + }); + }); + + describe("formatHome", () => { + test("replaces home directory with tilde on Unix", () => { + const home = os.homedir(); + const testPath = path.join(home, "projects", "cmux"); + const result = PlatformPaths.formatHome(testPath); + + // On Unix-like systems, should use tilde + if (process.platform !== "win32") { + expect(result).toBe("~/projects/cmux"); + } else { + // On Windows, should keep full path + expect(result).toContain(home); + } + }); + + test("leaves non-home paths unchanged", () => { + const result = PlatformPaths.formatHome("/tmp/test"); + expect(result).toBe("/tmp/test"); + }); + }); + + describe("expandHome", () => { + test("expands tilde to home directory", () => { + const home = os.homedir(); + expect(PlatformPaths.expandHome("~")).toBe(home); + }); + + test("expands tilde with path", () => { + const home = os.homedir(); + const sep = path.sep; + const result = PlatformPaths.expandHome(`~${sep}projects${sep}cmux`); + expect(result).toBe(path.join(home, "projects", "cmux")); + }); + + test("leaves absolute paths unchanged", () => { + const testPath = path.join("/", "home", "user", "project"); + expect(PlatformPaths.expandHome(testPath)).toBe(testPath); + }); + + test("handles empty input", () => { + expect(PlatformPaths.expandHome("")).toBe(""); + }); + }); + + describe("getProjectName", () => { + test("extracts project name from path", () => { + const testPath = path.join("/", "home", "user", "projects", "cmux"); + expect(PlatformPaths.getProjectName(testPath)).toBe("cmux"); + }); + + test("handles relative paths", () => { + expect(PlatformPaths.getProjectName("projects/cmux")).toBe("cmux"); + }); + + test("returns 'unknown' for empty path", () => { + expect(PlatformPaths.getProjectName("")).toBe("unknown"); + }); + }); + + describe("separator", () => { + test("returns correct separator for platform", () => { + const sep = PlatformPaths.separator; + // Should match the current platform's separator + expect(sep).toBe(path.sep); + }); + }); +}); diff --git a/src/utils/paths.ts b/src/utils/paths.ts new file mode 100644 index 000000000..4d32371a2 --- /dev/null +++ b/src/utils/paths.ts @@ -0,0 +1,227 @@ +/** + * Renderer-safe path utilities for cross-platform compatibility. + * Handles differences between Unix-style paths (/) and Windows paths (\). + * + * This module is safe for BOTH main and renderer, but intentionally avoids any + * Node globals (process/env). Main-only helpers live in './paths.main'. + */ + +export interface PathComponents { + root: string; // "/" on Unix, "C:\\" on Windows, "" for relative paths + segments: string[]; // Directory segments (excluding basename) + basename: string; // Final path component +} + +/** + * Determine if current platform is Windows (renderer-safe) + */ +function isWindowsPlatform(): boolean { + if (typeof navigator !== "undefined" && navigator.platform) { + return navigator.platform.toLowerCase().includes("win"); + } + // Default to Unix-style when navigator is unavailable (e.g., Node context) + return false; +} + +function getSeparator(): string { + return isWindowsPlatform() ? "\\" : "/"; +} + +/** + * OS-aware path utilities that handle Windows and Unix paths correctly. + * This class provides a single source of truth for path operations that need + * to be aware of platform differences. + */ +export class PlatformPaths { + /** + * Get the appropriate path separator for the current platform + */ + static get separator(): string { + return getSeparator(); + } + + /** + * Extract basename from path (OS-aware) + * + * @param filePath - Path to extract basename from + * @returns The final component of the path + * + * @example + * // Unix + * basename("/home/user/project") // => "project" + * + * // Windows + * basename("C:\\Users\\user\\project") // => "project" + */ + static basename(filePath: string): string { + if (!filePath || typeof filePath !== "string") { + return filePath; + } + + const lastSlash = isWindowsPlatform() + ? Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")) + : filePath.lastIndexOf("/"); + if (lastSlash === -1) { + return filePath; + } + return filePath.slice(lastSlash + 1); + } + + /** + * Split path into components (OS-aware) + * + * @param filePath - Path to parse + * @returns Object with root, segments, and basename + * + * @example + * // Unix + * parse("/home/user/project") // => { root: "/", segments: ["home", "user"], basename: "project" } + * + * // Windows + * parse("C:\\Users\\user\\project") // => { root: "C:\\", segments: ["Users", "user"], basename: "project" } + */ + static parse(filePath: string): PathComponents { + if (!filePath || typeof filePath !== "string") { + return { root: "", segments: [], basename: filePath }; + } + + const original = filePath; + let root = ""; + let dir = ""; + let base = ""; + + // Determine basename and directory + const lastSlash = isWindowsPlatform() + ? Math.max(original.lastIndexOf("/"), original.lastIndexOf("\\")) + : original.lastIndexOf("/"); + if (lastSlash === -1) { + base = original; + dir = ""; + } else { + base = original.slice(lastSlash + 1); + dir = original.slice(0, lastSlash); + } + + // Determine root + if (isWindowsPlatform()) { + const driveMatch = /^[A-Za-z]:[\\/]/.exec(original); + if (driveMatch) { + root = driveMatch[0]; + // Ensure dir does not include root + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } else if (original.startsWith("\\\\")) { + // UNC paths - treat leading double-backslash as root + root = "\\\\"; + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } + // Also treat Unix-style absolute paths as absolute even on Windows + if (!root && original.startsWith("/")) { + root = "/"; + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } + } else if (original.startsWith("/")) { + root = "/"; + if (dir.startsWith(root)) { + dir = dir.slice(root.length); + } + } + + const separatorRegex = isWindowsPlatform() ? /[\\/]+/ : /\/+/; + const segments = dir ? dir.split(separatorRegex).filter(Boolean) : []; + + return { + root, + segments, + basename: base, + }; + } + + /** + * Format path for display with fish-style abbreviation (OS-aware) + * Abbreviates all directory components except the last one to their first letter + * + * @param filePath - Path to abbreviate + * @returns Abbreviated path + * + * @example + * // Unix + * abbreviate("/home/user/Projects/cmux") // => "/h/u/P/cmux" + * + * // Windows + * abbreviate("C:\\Users\\john\\Documents\\project") // => "C:\\U\\j\\D\\project" + */ + static abbreviate(filePath: string): string { + if (!filePath || typeof filePath !== "string") { + return filePath; + } + + const { root, segments, basename } = this.parse(filePath); + + // Abbreviate all segments to first character + const abbreviated = segments.map((seg) => (seg.length > 0 ? seg[0] : seg)); + + // Reconstruct path - handle root separately to avoid double separator + if (!root && abbreviated.length === 0) { + return basename; + } + + const sep = isWindowsPlatform() ? (filePath.includes("\\") ? "\\" : "/") : "/"; + const joined = [...abbreviated, basename].filter(Boolean).join(sep); + if (!root) { + return joined; + } + const rootEndsWithSep = root.endsWith("\\") || root.endsWith("/"); + return rootEndsWithSep ? root + joined : root + sep + joined; + } + + /** + * Split an abbreviated path into directory path and basename + * + * @param filePath - Abbreviated path + * @returns Object with dirPath (including trailing separator) and basename + * + * @example + * splitAbbreviated("/h/u/P/cmux") // => { dirPath: "/h/u/P/", basename: "cmux" } + */ + static splitAbbreviated(filePath: string): { dirPath: string; basename: string } { + if (!filePath || typeof filePath !== "string") { + return { dirPath: "", basename: filePath }; + } + + const sep = isWindowsPlatform() ? (filePath.includes("\\") ? "\\" : "/") : "/"; + const lastSlash = filePath.lastIndexOf(sep); + if (lastSlash === -1) { + return { dirPath: "", basename: filePath }; + } + return { + dirPath: filePath.slice(0, lastSlash + 1), + basename: filePath.slice(lastSlash + 1), + }; + } + + /** + * NOTE: Home expansion and formatting helpers are main-only. + * Use './paths.main' for expandHome/formatHome in Node contexts. + */ + + /** + * Get project name from path (OS-aware) + * Extracts the final directory name from a project path + * + * @param projectPath - Path to the project + * @returns Project name (final directory component) + * + * @example + * getProjectName("/home/user/projects/cmux") // => "cmux" + * getProjectName("C:\\Users\\john\\projects\\cmux") // => "cmux" + */ + static getProjectName(projectPath: string): string { + return this.basename(projectPath) || "unknown"; + } +} diff --git a/src/utils/runtime/helpers.ts b/src/utils/runtime/helpers.ts index d958e5cde..ec2518dce 100644 --- a/src/utils/runtime/helpers.ts +++ b/src/utils/runtime/helpers.ts @@ -1,5 +1,5 @@ -import path from "path"; import type { Runtime, ExecOptions } from "@/runtime/Runtime"; +import { PlatformPaths } from "../paths"; /** * Convenience helpers for working with streaming Runtime APIs. @@ -11,9 +11,7 @@ import type { Runtime, ExecOptions } from "@/runtime/Runtime"; * Works for both local paths and remote paths */ export function getProjectName(projectPath: string): string { - // For local paths, use path.basename - // For remote paths (containing /), use the last segment - return path.basename(projectPath); + return PlatformPaths.getProjectName(projectPath); } /** diff --git a/src/utils/ui/pathAbbreviation.test.ts b/src/utils/ui/pathAbbreviation.test.ts deleted file mode 100644 index 13098c7f6..000000000 --- a/src/utils/ui/pathAbbreviation.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { abbreviatePath, splitAbbreviatedPath } from "./pathAbbreviation"; - -describe("abbreviatePath", () => { - it("should abbreviate all directory components except the last one", () => { - expect(abbreviatePath("/Users/ammar/Projects/coder/cmux")).toBe("/U/a/P/c/cmux"); - }); - - it("should handle paths without leading slash", () => { - expect(abbreviatePath("Users/ammar/Projects/coder/cmux")).toBe("U/a/P/c/cmux"); - }); - - it("should handle single directory paths", () => { - expect(abbreviatePath("/Users")).toBe("/Users"); - expect(abbreviatePath("Users")).toBe("Users"); - }); - - it("should handle root path", () => { - expect(abbreviatePath("/")).toBe("/"); - }); - - it("should handle empty string", () => { - expect(abbreviatePath("")).toBe(""); - }); - - it("should handle paths with multiple character directories", () => { - expect(abbreviatePath("/home/username/Documents/project")).toBe("/h/u/D/project"); - }); - - it("should preserve the full last directory name", () => { - expect(abbreviatePath("/Users/ammar/very-long-project-name")).toBe( - "/U/a/very-long-project-name" - ); - }); -}); - -describe("splitAbbreviatedPath", () => { - it("should split abbreviated path into directory and basename", () => { - expect(splitAbbreviatedPath("/U/a/P/c/mux")).toEqual({ - dirPath: "/U/a/P/c/", - basename: "mux", - }); - }); - - it("should handle paths without leading slash", () => { - expect(splitAbbreviatedPath("U/a/P/c/mux")).toEqual({ - dirPath: "U/a/P/c/", - basename: "mux", - }); - }); - - it("should handle single directory paths", () => { - expect(splitAbbreviatedPath("/Users")).toEqual({ - dirPath: "/", - basename: "Users", - }); - expect(splitAbbreviatedPath("Users")).toEqual({ - dirPath: "", - basename: "Users", - }); - }); - - it("should handle root path", () => { - expect(splitAbbreviatedPath("/")).toEqual({ - dirPath: "/", - basename: "", - }); - }); - - it("should handle empty string", () => { - expect(splitAbbreviatedPath("")).toEqual({ - dirPath: "", - basename: "", - }); - }); - - it("should handle paths with long basenames", () => { - expect(splitAbbreviatedPath("/U/a/very-long-project-name")).toEqual({ - dirPath: "/U/a/", - basename: "very-long-project-name", - }); - }); -}); diff --git a/src/utils/ui/pathAbbreviation.ts b/src/utils/ui/pathAbbreviation.ts deleted file mode 100644 index 51d430fc6..000000000 --- a/src/utils/ui/pathAbbreviation.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Fish-style path abbreviation utility - * Abbreviates all directory components except the last one to their first letter - * Example: /Users/ammar/Projects/coder/cmux -> /U/a/P/c/cmux - */ -export function abbreviatePath(path: string): string { - if (!path || typeof path !== "string") { - return path; - } - - const parts = path.split("/"); - - // Handle root path or empty parts - if (parts.length <= 1) { - return path; - } - - // Abbreviate all parts except the last one - const abbreviated = parts.map((part, index) => { - // Keep the last part full - if (index === parts.length - 1) { - return part; - } - // Keep empty parts (like leading slash) - if (part === "") { - return part; - } - // Abbreviate to first character - return part[0]; - }); - - return abbreviated.join("/"); -} - -/** - * Split an abbreviated path into directory path and basename - * Example: /U/a/P/c/cmux -> { dirPath: "/U/a/P/c/", basename: "mux" } - */ -export function splitAbbreviatedPath(path: string): { dirPath: string; basename: string } { - if (!path || typeof path !== "string") { - return { dirPath: "", basename: path }; - } - - const lastSlashIndex = path.lastIndexOf("/"); - if (lastSlashIndex === -1) { - return { dirPath: "", basename: path }; - } - - return { - dirPath: path.slice(0, lastSlashIndex + 1), // Include the trailing slash - basename: path.slice(lastSlashIndex + 1), - }; -} diff --git a/tsconfig.json b/tsconfig.json index 33d44c08e..a567f709c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,10 @@ "@/*": ["./src/*"] } }, + "watchOptions": { + "excludeDirectories": ["**/node_modules", "**/dist", "**/build", "**/.git"], + "excludeFiles": ["**/*.d.ts.map"] + }, "include": [ "src/**/*.tsx", "src/**/*.ts", diff --git a/tsconfig.main.json b/tsconfig.main.json index b63625bb8..489e5f627 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -6,6 +6,10 @@ "noEmit": false, "sourceMap": true }, + "watchOptions": { + "excludeDirectories": ["**/node_modules", "**/dist", "**/build", "**/.git"], + "excludeFiles": ["**/*.d.ts.map"] + }, "include": [ "src/main.ts", "src/main-server.ts", diff --git a/vite.config.ts b/vite.config.ts index 33b0332fd..553a9b88e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,6 +87,33 @@ export default defineConfig(({ mode }) => ({ strictPort: true, allowedHosts: devServerHost === "0.0.0.0" ? undefined : ["localhost", "127.0.0.1"], sourcemapIgnoreList: () => false, // Show all sources in DevTools + + watch: { + // Ignore node_modules to drastically reduce file handle usage + ignored: ['**/node_modules/**', '**/dist/**', '**/.git/**'], + + // Use polling on Windows to avoid file handle exhaustion + // This is slightly less efficient but much more stable + usePolling: process.platform === 'win32', + + // If using polling, set a reasonable interval (in milliseconds) + interval: 1000, + + // Limit the depth of directory traversal + depth: 3, + + // Additional options for Windows specifically + ...(process.platform === 'win32' && { + // Increase the binary interval for better Windows performance + binaryInterval: 1000, + // Use a more conservative approach to watching + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100 + } + }) + }, + hmr: { // Configure HMR to use the correct host for remote access host: devServerHost, @@ -104,5 +131,11 @@ export default defineConfig(({ mode }) => ({ esbuildOptions: { target: "esnext", }, + + // Include only what's actually imported to reduce scanning + entries: ['src/**/*.{ts,tsx}'], + + // Force re-optimize dependencies + force: false, }, }));