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
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export default defineConfig([
"src/debug/**/*.ts",
"src/git.ts",
"src/main.ts",
"src/config.test.ts",
"src/services/gitService.ts",
"src/services/log.ts",
"src/services/streamManager.ts",
Expand Down
177 changes: 110 additions & 67 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GlobalFonts } from "./styles/fonts";
import { GlobalScrollbars } from "./styles/scrollbars";
import type { ProjectConfig } from "./config";
import type { WorkspaceSelection } from "./components/ProjectSidebar";
import type { FrontendWorkspaceMetadata } from "./types/workspace";
import { LeftSidebar } from "./components/LeftSidebar";
import NewWorkspaceModal from "./components/NewWorkspaceModal";
import { AIView } from "./components/AIView";
Expand Down Expand Up @@ -172,12 +173,17 @@ function AppInner() {
[setProjects]
);

const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } =
useWorkspaceManagement({
selectedWorkspace,
onProjectsUpdate: handleProjectsUpdate,
onSelectedWorkspaceUpdate: setSelectedWorkspace,
});
const {
workspaceMetadata,
loading: metadataLoading,
createWorkspace,
removeWorkspace,
renameWorkspace,
} = useWorkspaceManagement({
selectedWorkspace,
onProjectsUpdate: handleProjectsUpdate,
onSelectedWorkspaceUpdate: setSelectedWorkspace,
});

// NEW: Sync workspace metadata with the stores
const workspaceStore = useWorkspaceStoreRaw();
Expand Down Expand Up @@ -215,8 +221,10 @@ function AppInner() {
window.history.replaceState(null, "", newHash);
}

// Update window title
const title = `${selectedWorkspace.workspaceId} - ${selectedWorkspace.projectName} - cmux`;
// Update window title with workspace name
const workspaceName =
workspaceMetadata.get(selectedWorkspace.workspaceId)?.name ?? selectedWorkspace.workspaceId;
const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`;
void window.api.window.setTitle(title);
} else {
// Clear hash when no workspace selected
Expand All @@ -225,42 +233,80 @@ function AppInner() {
}
void window.api.window.setTitle("cmux");
}
}, [selectedWorkspace]);
}, [selectedWorkspace, workspaceMetadata]);

// Restore workspace from URL on mount (if valid)
// This effect runs once on mount to restore from hash, which takes priority over localStorage
const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false);

useEffect(() => {
// Only run once
if (hasRestoredFromHash) return;

// Wait for metadata to finish loading
if (metadataLoading) return;

const hash = window.location.hash;
if (hash.startsWith("#workspace=")) {
const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));

// Find workspace in metadata
const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId);
const metadata = workspaceMetadata.get(workspaceId);

if (metadata) {
// Find project for this workspace
for (const [projectPath, projectConfig] of projects.entries()) {
const workspace = (projectConfig.workspaces ?? []).find(
(ws) => ws.path === metadata.workspacePath
);
if (workspace) {
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath,
projectName: metadata.projectName,
workspacePath: metadata.workspacePath,
});
break;
}
// Restore from hash (overrides localStorage)
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
namedWorkspacePath: metadata.namedWorkspacePath,
});
}
}

setHasRestoredFromHash(true);
}, [metadataLoading, workspaceMetadata, hasRestoredFromHash, setSelectedWorkspace]);

// Validate selected workspace exists and has all required fields
useEffect(() => {
// Don't validate until metadata is loaded
if (metadataLoading) return;

if (selectedWorkspace) {
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);

if (!metadata) {
// Workspace was deleted
console.warn(
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
);
setSelectedWorkspace(null);
if (window.location.hash) {
window.history.replaceState(null, "", window.location.pathname);
}
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
// Old localStorage entry missing namedWorkspacePath - update it once
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
namedWorkspacePath: metadata.namedWorkspacePath,
});
}
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [metadataLoading, selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);

const openWorkspaceInTerminal = useCallback((workspacePath: string) => {
void window.api.workspace.openTerminal(workspacePath);
}, []);
const openWorkspaceInTerminal = useCallback(
(workspaceId: string) => {
// Look up workspace metadata to get the named path (user-friendly symlink)
const metadata = workspaceMetadata.get(workspaceId);
if (metadata) {
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
}
},
[workspaceMetadata]
);

const handleRemoveProject = useCallback(
async (path: string) => {
Expand Down Expand Up @@ -364,33 +410,39 @@ function AppInner() {
const workspaceRecency = useWorkspaceRecency();

// Sort workspaces by recency (most recent first)
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
const sortedWorkspacesByProject = useStableReference(
() => {
const result = new Map<string, ProjectConfig["workspaces"]>();
const result = new Map<string, FrontendWorkspaceMetadata[]>();
for (const [projectPath, config] of projects) {
result.set(
projectPath,
(config.workspaces ?? []).slice().sort((a, b) => {
const aMeta = workspaceMetadata.get(a.path);
const bMeta = workspaceMetadata.get(b.path);
if (!aMeta || !bMeta) return 0;

// Get timestamp of most recent user message (0 if never used)
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
return bTimestamp - aTimestamp;
})
);
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
const metadataList = config.workspaces
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);

// Sort by recency
metadataList.sort((a, b) => {
const aTimestamp = workspaceRecency[a.id] ?? 0;
const bTimestamp = workspaceRecency[b.id] ?? 0;
return bTimestamp - aTimestamp;
});

result.set(projectPath, metadataList);
}
return result;
},
(prev, next) => {
// Compare Maps: check if both size and workspace order are the same
// Compare Maps: check if size, workspace order, and metadata content are the same
if (
!compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
return a.every((workspace, i) => workspace.path === b[i].path);
// Check both ID and name to detect renames
return a.every((metadata, i) => {
const bMeta = b[i];
if (!bMeta || !metadata) return false; // Null-safe
return metadata.id === bMeta.id && metadata.name === bMeta.name;
});
})
) {
return false;
Expand All @@ -410,7 +462,7 @@ function AppInner() {

// Find current workspace index in sorted list
const currentIndex = sortedWorkspaces.findIndex(
(ws) => ws.path === selectedWorkspace.workspacePath
(metadata) => metadata.id === selectedWorkspace.workspaceId
);
if (currentIndex === -1) return;

Expand All @@ -422,20 +474,17 @@ function AppInner() {
targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1;
}

const targetWorkspace = sortedWorkspaces[targetIndex];
if (!targetWorkspace) return;

const metadata = workspaceMetadata.get(targetWorkspace.path);
if (!metadata) return;
const targetMetadata = sortedWorkspaces[targetIndex];
if (!targetMetadata) return;

setSelectedWorkspace({
projectPath: selectedWorkspace.projectPath,
projectName: selectedWorkspace.projectName,
workspacePath: targetWorkspace.path,
workspaceId: metadata.id,
namedWorkspacePath: targetMetadata.namedWorkspacePath,
workspaceId: targetMetadata.id,
});
},
[selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace]
[selectedWorkspace, sortedWorkspacesByProject, setSelectedWorkspace]
);

// Register command sources with registry
Expand Down Expand Up @@ -534,12 +583,7 @@ function AppInner() {
);

const selectWorkspaceFromPalette = useCallback(
(selection: {
projectPath: string;
projectName: string;
workspacePath: string;
workspaceId: string;
}) => {
(selection: WorkspaceSelection) => {
setSelectedWorkspace(selection);
},
[setSelectedWorkspace]
Expand Down Expand Up @@ -679,20 +723,19 @@ function AppInner() {
/>
<MainContent>
<ContentArea>
{selectedWorkspace?.workspacePath ? (
{selectedWorkspace ? (
<ErrorBoundary
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId ?? "unknown"}`}
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId}`}
>
<AIView
key={selectedWorkspace.workspaceId}
workspaceId={selectedWorkspace.workspaceId}
projectName={selectedWorkspace.projectName}
branch={
selectedWorkspace.workspacePath?.split("/").pop() ??
selectedWorkspace.workspaceId ??
""
selectedWorkspace.namedWorkspacePath?.split("/").pop() ??
selectedWorkspace.workspaceId
}
workspacePath={selectedWorkspace.workspacePath}
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
/>
</ErrorBoundary>
) : (
Expand Down
10 changes: 5 additions & 5 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,15 @@ interface AIViewProps {
workspaceId: string;
projectName: string;
branch: string;
workspacePath: string;
namedWorkspacePath: string; // User-friendly path for display and terminal
className?: string;
}

const AIViewInner: React.FC<AIViewProps> = ({
workspaceId,
projectName,
branch,
workspacePath,
namedWorkspacePath,
className,
}) => {
const chatAreaRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -311,8 +311,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
);

const handleOpenTerminal = useCallback(() => {
void window.api.workspace.openTerminal(workspacePath);
}, [workspacePath]);
void window.api.workspace.openTerminal(namedWorkspacePath);
}, [namedWorkspacePath]);

// Auto-scroll when messages update (during streaming)
useEffect(() => {
Expand Down Expand Up @@ -443,7 +443,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
tooltipPosition="bottom"
/>
{projectName} / {branch}
<WorkspacePath>{workspacePath}</WorkspacePath>
<WorkspacePath>{namedWorkspacePath}</WorkspacePath>
<TooltipWrapper inline>
<TerminalIconButton onClick={handleOpenTerminal}>
<svg viewBox="0 0 16 16" fill="currentColor">
Expand Down
6 changes: 3 additions & 3 deletions src/components/LeftSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import styled from "@emotion/styled";
import type { ProjectConfig } from "@/config";
import type { WorkspaceMetadata } from "@/types/workspace";
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
import type { WorkspaceSelection } from "./ProjectSidebar";
import type { Secret } from "@/types/secrets";
import ProjectSidebar from "./ProjectSidebar";
Expand All @@ -21,7 +21,7 @@ const LeftSidebarContainer = styled.div<{ collapsed?: boolean }>`

interface LeftSidebarProps {
projects: Map<string, ProjectConfig>;
workspaceMetadata: Map<string, WorkspaceMetadata>;
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>;
selectedWorkspace: WorkspaceSelection | null;
onSelectWorkspace: (selection: WorkspaceSelection) => void;
onAddProject: () => void;
Expand All @@ -41,7 +41,7 @@ interface LeftSidebarProps {
onToggleCollapsed: () => void;
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
sortedWorkspacesByProject: Map<string, ProjectConfig["workspaces"]>;
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
}

export function LeftSidebar(props: LeftSidebarProps) {
Expand Down
Loading