Skip to content

Commit 4cc5192

Browse files
committed
Refactor: eliminate pathToMetadata code smell
## Changes 1. Rename type: WorkspaceMetadataWithPaths → FrontendWorkspaceMetadata 2. sortedWorkspacesByProject returns metadata arrays directly 3. Removed duplicate pathToMetadata maps from App.tsx and ProjectSidebar 4. WorkspaceListItem accepts metadata object (6 props → 1) 5. Updated keyboard navigation to work with metadata ## Benefits - Net: -23 lines (removed duplicate logic) - Clearer data flow: pass data, not lookup maps - Simpler component API: metadata object vs 6 props 16 files changed, 124 insertions(+), 147 deletions(-)
1 parent 79e0cda commit 4cc5192

File tree

16 files changed

+123
-147
lines changed

16 files changed

+123
-147
lines changed

src/App.tsx

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { GlobalFonts } from "./styles/fonts";
66
import { GlobalScrollbars } from "./styles/scrollbars";
77
import type { ProjectConfig } from "./config";
88
import type { WorkspaceSelection } from "./components/ProjectSidebar";
9-
import type { WorkspaceMetadataWithPaths } from "./types/workspace";
9+
import type { FrontendWorkspaceMetadata } from "./types/workspace";
1010
import { LeftSidebar } from "./components/LeftSidebar";
1111
import NewWorkspaceModal from "./components/NewWorkspaceModal";
1212
import { AIView } from "./components/AIView";
@@ -173,17 +173,6 @@ function AppInner() {
173173
onSelectedWorkspaceUpdate: setSelectedWorkspace,
174174
});
175175

176-
// Build path-to-metadata lookup map (handles both stable and legacy paths)
177-
const pathToMetadata = useMemo(() => {
178-
const map = new Map<string, WorkspaceMetadataWithPaths>();
179-
for (const metadata of workspaceMetadata.values()) {
180-
// Map both stable path (with ID) and named path (with name/legacy)
181-
map.set(metadata.stableWorkspacePath, metadata);
182-
map.set(metadata.namedWorkspacePath, metadata);
183-
}
184-
return map;
185-
}, [workspaceMetadata]);
186-
187176
// NEW: Sync workspace metadata with the stores
188177
const workspaceStore = useWorkspaceStoreRaw();
189178
const gitStatusStore = useGitStatusStoreRaw();
@@ -342,25 +331,32 @@ function AppInner() {
342331
const workspaceRecency = useWorkspaceRecency();
343332

344333
// Sort workspaces by recency (most recent first)
334+
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
345335
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
346336
const sortedWorkspacesByProject = useStableReference(
347337
() => {
348-
const result = new Map<string, ProjectConfig["workspaces"]>();
338+
// Build path-to-metadata lookup map internally
339+
const pathToMetadata = new Map<string, FrontendWorkspaceMetadata>();
340+
for (const metadata of workspaceMetadata.values()) {
341+
pathToMetadata.set(metadata.stableWorkspacePath, metadata);
342+
pathToMetadata.set(metadata.namedWorkspacePath, metadata);
343+
}
344+
345+
const result = new Map<string, FrontendWorkspaceMetadata[]>();
349346
for (const [projectPath, config] of projects) {
350-
result.set(
351-
projectPath,
352-
config.workspaces.slice().sort((a, b) => {
353-
// Look up metadata by workspace path (handles both stable and legacy paths)
354-
const aMeta = pathToMetadata.get(a.path);
355-
const bMeta = pathToMetadata.get(b.path);
356-
if (!aMeta || !bMeta) return 0;
357-
358-
// Get timestamp of most recent user message (0 if never used)
359-
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
360-
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
361-
return bTimestamp - aTimestamp;
362-
})
363-
);
347+
// Transform Workspace[] to FrontendWorkspaceMetadata[] and filter nulls
348+
const metadataList = config.workspaces
349+
.map((ws) => pathToMetadata.get(ws.path))
350+
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined);
351+
352+
// Sort by recency
353+
metadataList.sort((a, b) => {
354+
const aTimestamp = workspaceRecency[a.id] ?? 0;
355+
const bTimestamp = workspaceRecency[b.id] ?? 0;
356+
return bTimestamp - aTimestamp;
357+
});
358+
359+
result.set(projectPath, metadataList);
364360
}
365361
return result;
366362
},
@@ -369,14 +365,14 @@ function AppInner() {
369365
if (
370366
!compareMaps(prev, next, (a, b) => {
371367
if (a.length !== b.length) return false;
372-
return a.every((workspace, i) => workspace.path === b[i].path);
368+
return a.every((metadata, i) => metadata.id === b[i].id);
373369
})
374370
) {
375371
return false;
376372
}
377373
return true;
378374
},
379-
[projects, workspaceMetadata, workspaceRecency, pathToMetadata]
375+
[projects, workspaceMetadata, workspaceRecency]
380376
);
381377

382378
const handleNavigateWorkspace = useCallback(
@@ -389,7 +385,7 @@ function AppInner() {
389385

390386
// Find current workspace index in sorted list
391387
const currentIndex = sortedWorkspaces.findIndex(
392-
(ws) => ws.path === selectedWorkspace.workspacePath
388+
(metadata) => metadata.id === selectedWorkspace.workspaceId
393389
);
394390
if (currentIndex === -1) return;
395391

@@ -401,21 +397,17 @@ function AppInner() {
401397
targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1;
402398
}
403399

404-
const targetWorkspace = sortedWorkspaces[targetIndex];
405-
if (!targetWorkspace) return;
406-
407-
// Look up metadata by workspace path (handles both stable and legacy paths)
408-
const metadata = pathToMetadata.get(targetWorkspace.path);
409-
if (!metadata) return;
400+
const targetMetadata = sortedWorkspaces[targetIndex];
401+
if (!targetMetadata) return;
410402

411403
setSelectedWorkspace({
412404
projectPath: selectedWorkspace.projectPath,
413405
projectName: selectedWorkspace.projectName,
414-
workspacePath: targetWorkspace.path,
415-
workspaceId: metadata.id,
406+
workspacePath: targetMetadata.stableWorkspacePath,
407+
workspaceId: targetMetadata.id,
416408
});
417409
},
418-
[selectedWorkspace, sortedWorkspacesByProject, pathToMetadata, setSelectedWorkspace]
410+
[selectedWorkspace, sortedWorkspacesByProject, setSelectedWorkspace]
419411
);
420412

421413
// Register command sources with registry

src/components/LeftSidebar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import styled from "@emotion/styled";
33
import type { ProjectConfig } from "@/config";
4-
import type { WorkspaceMetadataWithPaths } from "@/types/workspace";
4+
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
55
import type { WorkspaceSelection } from "./ProjectSidebar";
66
import type { Secret } from "@/types/secrets";
77
import ProjectSidebar from "./ProjectSidebar";
@@ -21,7 +21,7 @@ const LeftSidebarContainer = styled.div<{ collapsed?: boolean }>`
2121

2222
interface LeftSidebarProps {
2323
projects: Map<string, ProjectConfig>;
24-
workspaceMetadata: Map<string, WorkspaceMetadataWithPaths>;
24+
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>;
2525
selectedWorkspace: WorkspaceSelection | null;
2626
onSelectWorkspace: (selection: WorkspaceSelection) => void;
2727
onAddProject: () => void;
@@ -41,7 +41,7 @@ interface LeftSidebarProps {
4141
onToggleCollapsed: () => void;
4242
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
4343
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
44-
sortedWorkspacesByProject: Map<string, ProjectConfig["workspaces"]>;
44+
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
4545
}
4646

4747
export function LeftSidebar(props: LeftSidebarProps) {

src/components/ProjectSidebar.tsx

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
33
import styled from "@emotion/styled";
44
import { css } from "@emotion/react";
55
import type { ProjectConfig, Workspace } from "@/config";
6-
import type { WorkspaceMetadataWithPaths } from "@/types/workspace";
6+
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
77
import { usePersistedState } from "@/hooks/usePersistedState";
88
import { DndProvider } from "react-dnd";
99
import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend";
@@ -471,7 +471,7 @@ const ProjectDragLayer: React.FC = () => {
471471

472472
interface ProjectSidebarProps {
473473
projects: Map<string, ProjectConfig>;
474-
workspaceMetadata: Map<string, WorkspaceMetadataWithPaths>;
474+
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>;
475475
selectedWorkspace: WorkspaceSelection | null;
476476
onSelectWorkspace: (selection: WorkspaceSelection) => void;
477477
onAddProject: () => void;
@@ -491,7 +491,7 @@ interface ProjectSidebarProps {
491491
onToggleCollapsed: () => void;
492492
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
493493
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
494-
sortedWorkspacesByProject: Map<string, Workspace[]>;
494+
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
495495
}
496496

497497
const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
@@ -512,17 +512,6 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
512512
onUpdateSecrets,
513513
sortedWorkspacesByProject,
514514
}) => {
515-
// Build path-to-metadata lookup map (handles both stable and legacy paths)
516-
const pathToMetadata = React.useMemo(() => {
517-
const map = new Map<string, WorkspaceMetadataWithPaths>();
518-
for (const metadata of workspaceMetadata.values()) {
519-
// Map both stable path (with ID) and named path (with name/legacy)
520-
map.set(metadata.stableWorkspacePath, metadata);
521-
map.set(metadata.namedWorkspacePath, metadata);
522-
}
523-
return map;
524-
}, [workspaceMetadata]);
525-
526515
// Workspace-specific subscriptions moved to WorkspaceListItem component
527516

528517
// Store as array in localStorage, convert to Set for usage
@@ -831,33 +820,23 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
831820
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
832821
</AddWorkspaceBtn>
833822
</WorkspaceHeader>
834-
{(sortedWorkspacesByProject.get(projectPath) ?? config.workspaces).map(
835-
(workspace) => {
836-
// Look up metadata by workspace path (handles both stable ID paths and legacy name paths)
837-
const metadata = pathToMetadata.get(workspace.path);
838-
if (!metadata) return null;
839-
840-
const workspaceId = metadata.id;
841-
const isSelected =
842-
selectedWorkspace?.workspacePath === workspace.path;
843-
844-
return (
845-
<WorkspaceListItem
846-
key={workspace.path}
847-
workspaceId={workspaceId}
848-
workspaceName={metadata.name}
849-
workspacePath={workspace.path}
850-
projectPath={projectPath}
851-
projectName={projectName}
852-
isSelected={isSelected}
853-
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
854-
onSelectWorkspace={onSelectWorkspace}
855-
onRemoveWorkspace={handleRemoveWorkspace}
856-
onToggleUnread={_onToggleUnread}
857-
/>
858-
);
859-
}
860-
)}
823+
{sortedWorkspacesByProject.get(projectPath)?.map((metadata) => {
824+
const isSelected = selectedWorkspace?.workspaceId === metadata.id;
825+
826+
return (
827+
<WorkspaceListItem
828+
key={metadata.id}
829+
metadata={metadata}
830+
projectPath={projectPath}
831+
projectName={projectName}
832+
isSelected={isSelected}
833+
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
834+
onSelectWorkspace={onSelectWorkspace}
835+
onRemoveWorkspace={handleRemoveWorkspace}
836+
onToggleUnread={_onToggleUnread}
837+
/>
838+
);
839+
})}
861840
</WorkspacesContainer>
862841
)}
863842
</ProjectGroup>

src/components/WorkspaceListItem.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useCallback, useMemo } from "react";
22
import styled from "@emotion/styled";
33
import { css } from "@emotion/react";
4+
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
45
import { useWorkspaceSidebarState } from "@/stores/WorkspaceStore";
56
import { useGitStatus } from "@/stores/GitStatusStore";
67
import { formatRelativeTime } from "@/utils/ui/dateTime";
@@ -126,10 +127,8 @@ export interface WorkspaceSelection {
126127
workspaceId: string;
127128
}
128129
export interface WorkspaceListItemProps {
129-
// Minimal data - component accesses stores directly for the rest
130-
workspaceId: string;
131-
workspaceName: string; // User-facing workspace name (from metadata)
132-
workspacePath: string;
130+
// Workspace metadata passed directly
131+
metadata: FrontendWorkspaceMetadata;
133132
projectPath: string;
134133
projectName: string;
135134
isSelected: boolean;
@@ -141,9 +140,7 @@ export interface WorkspaceListItemProps {
141140
}
142141

143142
const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
144-
workspaceId,
145-
workspaceName,
146-
workspacePath,
143+
metadata,
147144
projectPath,
148145
projectName,
149146
isSelected,
@@ -152,6 +149,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
152149
onRemoveWorkspace,
153150
onToggleUnread,
154151
}) => {
152+
// Destructure metadata for convenience
153+
const { id: workspaceId, name: workspaceName, stableWorkspacePath: workspacePath } = metadata;
155154
// Subscribe to this specific workspace's sidebar state (streaming status, model, recency)
156155
const sidebarState = useWorkspaceSidebarState(workspaceId);
157156
const gitStatus = useGitStatus(workspaceId);

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export class Config {
277277
* config.getWorkspacePath(). This ensures single source of truth for path format.
278278
*
279279
* Backend: Uses getWorkspacePath(metadata.projectPath, metadata.id) for operations
280-
* Frontend: Gets enriched metadata with paths via IPC (WorkspaceMetadataWithPaths)
280+
* Frontend: Gets enriched metadata with paths via IPC (FrontendWorkspaceMetadata)
281281
*
282282
* WorkspaceMetadata.workspacePath is deprecated and will be removed. Use computed
283283
* paths from getWorkspacePath() or getWorkspacePaths() instead.

src/hooks/useWorkspaceManagement.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback } from "react";
2-
import type { WorkspaceMetadataWithPaths } from "@/types/workspace";
2+
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
33
import type { WorkspaceSelection } from "@/components/ProjectSidebar";
44
import type { ProjectConfig } from "@/config";
55

@@ -18,7 +18,7 @@ export function useWorkspaceManagement({
1818
onSelectedWorkspaceUpdate,
1919
}: UseWorkspaceManagementProps) {
2020
const [workspaceMetadata, setWorkspaceMetadata] = useState<
21-
Map<string, WorkspaceMetadataWithPaths>
21+
Map<string, FrontendWorkspaceMetadata>
2222
>(new Map());
2323

2424
const loadWorkspaceMetadata = useCallback(async () => {

src/preload.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
import { contextBridge, ipcRenderer } from "electron";
2222
import type { IPCApi, WorkspaceChatMessage } from "./types/ipc";
23-
import type { WorkspaceMetadataWithPaths } from "./types/workspace";
23+
import type { FrontendWorkspaceMetadata } from "./types/workspace";
2424
import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants";
2525

2626
// Build the API implementation using the shared interface
@@ -88,11 +88,11 @@ const api: IPCApi = {
8888
};
8989
},
9090
onMetadata: (
91-
callback: (data: { workspaceId: string; metadata: WorkspaceMetadataWithPaths }) => void
91+
callback: (data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }) => void
9292
) => {
9393
const handler = (
9494
_event: unknown,
95-
data: { workspaceId: string; metadata: WorkspaceMetadataWithPaths }
95+
data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }
9696
) => callback(data);
9797

9898
// Subscribe to metadata events

src/services/ipcMain.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { createBashTool } from "@/services/tools/bash";
2525
import type { BashToolResult } from "@/types/tools";
2626
import { secretsToRecord } from "@/types/secrets";
2727
import { DisposableTempDir } from "@/services/tempDir";
28-
import type { WorkspaceMetadata, WorkspaceMetadataWithPaths } from "@/types/workspace";
28+
import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/types/workspace";
2929

3030
/**
3131
* IpcMain - Manages all IPC handlers and service coordination
@@ -126,7 +126,7 @@ export class IpcMain {
126126
* Enrich workspace metadata with computed paths for frontend use.
127127
* Backend computes these to avoid duplicating path logic on frontend.
128128
*/
129-
private enrichMetadataWithPaths(metadata: WorkspaceMetadata): WorkspaceMetadataWithPaths {
129+
private enrichMetadataWithPaths(metadata: WorkspaceMetadata): FrontendWorkspaceMetadata {
130130
const paths = this.config.getWorkspacePaths(metadata);
131131
return {
132132
...metadata,

0 commit comments

Comments
 (0)