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
11 changes: 11 additions & 0 deletions .github/actions/setup-mux/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
51 changes: 44 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]'
Expand All @@ -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)"
Expand All @@ -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



Expand Down Expand Up @@ -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
Expand All @@ -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..."
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"win": {
"target": "nsis"
"target": "nsis",
"icon": "build/icon.png",
"artifactName": "${productName}-${version}-${arch}.${ext}"
}
}
}
15 changes: 14 additions & 1 deletion public/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
});
});
})
);
});
36 changes: 36 additions & 0 deletions scripts/build-main-watch.js
Original file line number Diff line number Diff line change
@@ -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);
}

13 changes: 7 additions & 6 deletions src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="pointer-events-none fixed inset-0 z-[9999] cursor-grabbing">
Expand Down Expand Up @@ -238,7 +238,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
if (!path || typeof path !== "string") {
return "Unknown";
}
return path.split("/").pop() ?? path.split("\\").pop() ?? path;
return PlatformPaths.getProjectName(path);
};

const toggleProject = (projectPath: string) => {
Expand Down Expand Up @@ -498,8 +498,9 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
<TooltipWrapper inline>
<div className="text-muted-dark font-monospace truncate text-sm leading-tight">
{(() => {
const abbrevPath = abbreviatePath(projectPath);
const { dirPath, basename } = splitAbbreviatedPath(abbrevPath);
const abbrevPath = PlatformPaths.abbreviate(projectPath);
const { dirPath, basename } =
PlatformPaths.splitAbbreviated(abbrevPath);
return (
<>
<span>{dirPath}</span>
Expand Down
6 changes: 3 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -96,7 +97,7 @@ export class Config {
}

private getProjectName(projectPath: string): string {
return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
return PlatformPaths.getProjectName(projectPath);
}

/**
Expand All @@ -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}`;
}

Expand Down
5 changes: 3 additions & 2 deletions src/debug/agentSessionCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -168,8 +169,8 @@ async function main(): Promise<void> {
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;
Expand Down
6 changes: 3 additions & 3 deletions src/debug/list-workspaces.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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}`);
Expand Down
24 changes: 14 additions & 10 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
}
Loading