Skip to content

Commit 04898dc

Browse files
authored
🤖 Fix Ctrl+J/K workspace navigation to use sorted order (#209)
Workspace navigation (Ctrl+J/K) now follows the visual order displayed in the sidebar. ## Problem PR #205 added recency-based sorting to the workspace display, but Ctrl+J/K navigation still used the unsorted config order. This caused confusion where pressing "next workspace" wouldn't select the next visible workspace in the sidebar. ## Solution Moved the sorting logic from ProjectSidebar to App.tsx so both navigation and display use the same recency-sorted workspace list. This ensures Ctrl+J/K navigation matches the visual order users see. ## Changes - Added `sortedWorkspacesByProject` memo in App.tsx that sorts workspaces by recency - Updated `handleNavigateWorkspace` to use the sorted list instead of raw config order - Pass sorted list through LeftSidebar → ProjectSidebar props chain - Removed duplicate sorting logic from ProjectSidebar (now uses parent's sorted list) ## Testing - ✅ All 379 unit tests pass - ✅ Type checking passes - Manual verification: Ctrl+J/K now navigates in the same order as displayed in sidebar _Generated with `cmux`_
1 parent 539d428 commit 04898dc

File tree

3 files changed

+37
-36
lines changed

3 files changed

+37
-36
lines changed

src/App.tsx

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback, useRef } from "react";
1+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
22
import styled from "@emotion/styled";
33
import { Global, css } from "@emotion/react";
44
import { GlobalColors } from "./styles/colors";
@@ -287,28 +287,51 @@ function AppInner() {
287287
[]
288288
);
289289

290+
// Sort workspaces by recency (most recent first)
291+
// This ensures navigation follows the visual order displayed in the sidebar
292+
const sortedWorkspacesByProject = useMemo(() => {
293+
const result = new Map<string, ProjectConfig["workspaces"]>();
294+
for (const [projectPath, config] of projects) {
295+
result.set(
296+
projectPath,
297+
config.workspaces.slice().sort((a, b) => {
298+
const aMeta = workspaceMetadata.get(a.path);
299+
const bMeta = workspaceMetadata.get(b.path);
300+
if (!aMeta || !bMeta) return 0;
301+
302+
// Get timestamp of most recent user message (0 if never used)
303+
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
304+
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
305+
return bTimestamp - aTimestamp;
306+
})
307+
);
308+
}
309+
return result;
310+
}, [projects, workspaceMetadata, workspaceRecency]);
311+
290312
const handleNavigateWorkspace = useCallback(
291313
(direction: "next" | "prev") => {
292314
if (!selectedWorkspace) return;
293315

294-
const projectConfig = projects.get(selectedWorkspace.projectPath);
295-
if (!projectConfig || projectConfig.workspaces.length <= 1) return;
316+
// Use sorted workspaces to match visual order in sidebar
317+
const sortedWorkspaces = sortedWorkspacesByProject.get(selectedWorkspace.projectPath);
318+
if (!sortedWorkspaces || sortedWorkspaces.length <= 1) return;
296319

297-
// Find current workspace index
298-
const currentIndex = projectConfig.workspaces.findIndex(
320+
// Find current workspace index in sorted list
321+
const currentIndex = sortedWorkspaces.findIndex(
299322
(ws) => ws.path === selectedWorkspace.workspacePath
300323
);
301324
if (currentIndex === -1) return;
302325

303326
// Calculate next/prev index with wrapping
304327
let targetIndex: number;
305328
if (direction === "next") {
306-
targetIndex = (currentIndex + 1) % projectConfig.workspaces.length;
329+
targetIndex = (currentIndex + 1) % sortedWorkspaces.length;
307330
} else {
308-
targetIndex = currentIndex === 0 ? projectConfig.workspaces.length - 1 : currentIndex - 1;
331+
targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1;
309332
}
310333

311-
const targetWorkspace = projectConfig.workspaces[targetIndex];
334+
const targetWorkspace = sortedWorkspaces[targetIndex];
312335
if (!targetWorkspace) return;
313336

314337
const metadata = workspaceMetadata.get(targetWorkspace.path);
@@ -321,7 +344,7 @@ function AppInner() {
321344
workspaceId: metadata.id,
322345
});
323346
},
324-
[selectedWorkspace, projects, workspaceMetadata, setSelectedWorkspace]
347+
[selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace]
325348
);
326349

327350
// Register command sources with registry
@@ -554,7 +577,7 @@ function AppInner() {
554577
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
555578
onGetSecrets={handleGetSecrets}
556579
onUpdateSecrets={handleUpdateSecrets}
557-
workspaceRecency={workspaceRecency}
580+
sortedWorkspacesByProject={sortedWorkspacesByProject}
558581
/>
559582
<MainContent>
560583
<ContentArea>

src/components/LeftSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ interface LeftSidebarProps {
4343
onToggleCollapsed: () => void;
4444
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
4545
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
46-
workspaceRecency: Record<string, number>;
46+
sortedWorkspacesByProject: Map<string, ProjectConfig["workspaces"]>;
4747
}
4848

4949
export function LeftSidebar(props: LeftSidebarProps) {

src/components/ProjectSidebar.tsx

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
1+
import React, { useState, useEffect, useCallback, useRef } from "react";
22
import { createPortal } from "react-dom";
33
import styled from "@emotion/styled";
44
import { css } from "@emotion/react";
@@ -585,7 +585,7 @@ interface ProjectSidebarProps {
585585
onToggleCollapsed: () => void;
586586
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
587587
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
588-
workspaceRecency: Record<string, number>;
588+
sortedWorkspacesByProject: Map<string, Workspace[]>;
589589
}
590590

591591
const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
@@ -605,33 +605,11 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
605605
onToggleCollapsed,
606606
onGetSecrets,
607607
onUpdateSecrets,
608-
workspaceRecency,
608+
sortedWorkspacesByProject,
609609
}) => {
610610
// Subscribe to git status updates (causes this component to re-render every 10s)
611611
const gitStatus = useGitStatus();
612612

613-
// Sort workspaces by last user message (most recent first)
614-
// workspaceRecency only updates when timestamps actually change (stable reference optimization)
615-
const sortedWorkspacesByProject = useMemo(() => {
616-
const result = new Map<string, Workspace[]>();
617-
for (const [projectPath, config] of projects) {
618-
result.set(
619-
projectPath,
620-
config.workspaces.slice().sort((a, b) => {
621-
const aMeta = workspaceMetadata.get(a.path);
622-
const bMeta = workspaceMetadata.get(b.path);
623-
if (!aMeta || !bMeta) return 0;
624-
625-
// Get timestamp of most recent user message (0 if never used)
626-
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
627-
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
628-
return bTimestamp - aTimestamp;
629-
})
630-
);
631-
}
632-
return result;
633-
}, [projects, workspaceMetadata, workspaceRecency]);
634-
635613
// Store as array in localStorage, convert to Set for usage
636614
const [expandedProjectsArray, setExpandedProjectsArray] = usePersistedState<string[]>(
637615
"expandedProjects",

0 commit comments

Comments
 (0)