Skip to content

Commit af4c2af

Browse files
authored
Split projects into it's own context with tests (#600)
We should not use props to pass context. It's very complex for testing and inefficient long-term. In a future PR I will split out workspaces.
1 parent dea7368 commit af4c2af

File tree

12 files changed

+834
-293
lines changed

12 files changed

+834
-293
lines changed

bun.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"eslint-plugin-storybook": "10.0.0",
9090
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
9191
"geist": "^1.5.1",
92+
"happy-dom": "^20.0.10",
9293
"jest": "^30.1.3",
9394
"mermaid": "^11.12.0",
9495
"nodemon": "^3.1.10",
@@ -957,6 +958,8 @@
957958

958959
"@types/wait-on": ["@types/wait-on@5.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw=="],
959960

961+
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
962+
960963
"@types/write-file-atomic": ["@types/write-file-atomic@4.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ=="],
961964

962965
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -1759,6 +1762,8 @@
17591762

17601763
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
17611764

1765+
"happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="],
1766+
17621767
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
17631768

17641769
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -2993,6 +2998,8 @@
29932998

29942999
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
29953000

3001+
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
3002+
29963003
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
29973004

29983005
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -3243,6 +3250,8 @@
32433250

32443251
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
32453252

3253+
"happy-dom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],
3254+
32463255
"hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="],
32473256

32483257
"hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
@@ -3515,6 +3524,8 @@
35153524

35163525
"global-prefix/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
35173526

3527+
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
3528+
35183529
"jest-circus/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
35193530

35203531
"jest-cli/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"eslint-plugin-storybook": "10.0.0",
129129
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
130130
"geist": "^1.5.1",
131+
"happy-dom": "^20.0.10",
131132
"jest": "^30.1.3",
132133
"mermaid": "^11.12.0",
133134
"nodemon": "^3.1.10",

src/App.tsx

Lines changed: 40 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState, useEffect, useCallback, useRef } from "react";
1+
import { useEffect, useCallback, useRef } from "react";
22
import "./styles/globals.css";
33
import { useApp } from "./contexts/AppContext";
4+
import { useProjectContext } from "./contexts/ProjectContext";
5+
import { useSortedWorkspacesByProject } from "./hooks/useSortedWorkspacesByProject";
46
import type { WorkspaceSelection } from "./components/ProjectSidebar";
5-
import type { FrontendWorkspaceMetadata } from "./types/workspace";
67
import { LeftSidebar } from "./components/LeftSidebar";
78
import { ProjectCreateModal } from "./components/ProjectCreateModal";
89
import { AIView } from "./components/AIView";
@@ -12,11 +13,10 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
1213
import { useResumeManager } from "./hooks/useResumeManager";
1314
import { useUnreadTracking } from "./hooks/useUnreadTracking";
1415
import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
15-
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
16+
import { useWorkspaceStoreRaw } from "./stores/WorkspaceStore";
1617
import { ChatInput } from "./components/ChatInput/index";
1718
import type { ChatInputAPI } from "./components/ChatInput/types";
1819

19-
import { useStableReference, compareMaps } from "./hooks/useStableReference";
2020
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
2121
import type { CommandAction } from "./contexts/CommandRegistryContext";
2222
import { ModeProvider } from "./contexts/ModeContext";
@@ -28,7 +28,6 @@ import type { ThinkingLevel } from "./types/thinking";
2828
import { CUSTOM_EVENTS } from "./constants/events";
2929
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork";
3030
import { getThinkingLevelKey } from "./constants/storage";
31-
import type { BranchListResult } from "./types/ipc";
3231
import { useTelemetry } from "./hooks/useTelemetry";
3332
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
3433

@@ -37,20 +36,25 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
3736
function AppInner() {
3837
// Get app-level state from context
3938
const {
40-
projects,
41-
addProject,
42-
removeProject,
4339
workspaceMetadata,
4440
setWorkspaceMetadata,
4541
removeWorkspace,
4642
renameWorkspace,
4743
selectedWorkspace,
4844
setSelectedWorkspace,
4945
} = useApp();
50-
const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false);
51-
52-
// Track when we're in "new workspace creation" mode (show FirstMessageInput)
53-
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);
46+
const {
47+
projects,
48+
addProject,
49+
removeProject: removeProjectFromContext,
50+
isProjectCreateModalOpen,
51+
openProjectCreateModal,
52+
closeProjectCreateModal,
53+
pendingNewWorkspaceProject,
54+
beginWorkspaceCreation,
55+
clearPendingWorkspaceCreation,
56+
getBranchesForProject,
57+
} = useProjectContext();
5458

5559
// Auto-collapse sidebar on mobile by default
5660
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
@@ -67,7 +71,13 @@ function AppInner() {
6771

6872
const startWorkspaceCreation = useStartWorkspaceCreation({
6973
projects,
70-
setPendingNewWorkspaceProject,
74+
setPendingNewWorkspaceProject: (projectPath: string | null) => {
75+
if (projectPath) {
76+
beginWorkspaceCreation(projectPath);
77+
} else {
78+
clearPendingWorkspaceCreation();
79+
}
80+
},
7181
setSelectedWorkspace,
7282
});
7383

@@ -179,96 +189,22 @@ function AppInner() {
179189
if (selectedWorkspace?.projectPath === path) {
180190
setSelectedWorkspace(null);
181191
}
182-
await removeProject(path);
183-
},
184-
[removeProject, selectedWorkspace, setSelectedWorkspace]
185-
);
186-
187-
const handleAddWorkspace = useCallback(
188-
(projectPath: string) => {
189-
startWorkspaceCreation(projectPath);
190-
},
191-
[startWorkspaceCreation]
192-
);
193-
194-
// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
195-
const handleAddProjectCallback = useCallback(() => {
196-
setProjectCreateModalOpen(true);
197-
}, []);
198-
199-
const handleAddWorkspaceCallback = useCallback(
200-
(projectPath: string) => {
201-
void handleAddWorkspace(projectPath);
202-
},
203-
[handleAddWorkspace]
204-
);
205-
206-
const handleRemoveProjectCallback = useCallback(
207-
(path: string) => {
208-
void handleRemoveProject(path);
209-
},
210-
[handleRemoveProject]
211-
);
212-
213-
const handleGetSecrets = useCallback(async (projectPath: string) => {
214-
return await window.api.projects.secrets.get(projectPath);
215-
}, []);
216-
217-
const handleUpdateSecrets = useCallback(
218-
async (projectPath: string, secrets: Array<{ key: string; value: string }>) => {
219-
const result = await window.api.projects.secrets.update(projectPath, secrets);
220-
if (!result.success) {
221-
console.error("Failed to update secrets:", result.error);
192+
if (pendingNewWorkspaceProject === path) {
193+
clearPendingWorkspaceCreation();
222194
}
195+
await removeProjectFromContext(path);
223196
},
224-
[]
197+
[
198+
clearPendingWorkspaceCreation,
199+
pendingNewWorkspaceProject,
200+
removeProjectFromContext,
201+
selectedWorkspace,
202+
setSelectedWorkspace,
203+
]
225204
);
226205

227206
// NEW: Get workspace recency from store
228-
const workspaceRecency = useWorkspaceRecency();
229-
230-
// Sort workspaces by recency (most recent first)
231-
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
232-
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
233-
const sortedWorkspacesByProject = useStableReference(
234-
() => {
235-
const result = new Map<string, FrontendWorkspaceMetadata[]>();
236-
for (const [projectPath, config] of projects) {
237-
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
238-
const metadataList = config.workspaces
239-
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
240-
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);
241-
242-
// Sort by recency
243-
metadataList.sort((a, b) => {
244-
const aTimestamp = workspaceRecency[a.id] ?? 0;
245-
const bTimestamp = workspaceRecency[b.id] ?? 0;
246-
return bTimestamp - aTimestamp;
247-
});
248-
249-
result.set(projectPath, metadataList);
250-
}
251-
return result;
252-
},
253-
(prev, next) => {
254-
// Compare Maps: check if size, workspace order, and metadata content are the same
255-
if (
256-
!compareMaps(prev, next, (a, b) => {
257-
if (a.length !== b.length) return false;
258-
// Check both ID and name to detect renames
259-
return a.every((metadata, i) => {
260-
const bMeta = b[i];
261-
if (!bMeta || !metadata) return false; // Null-safe
262-
return metadata.id === bMeta.id && metadata.name === bMeta.name;
263-
});
264-
})
265-
) {
266-
return false;
267-
}
268-
return true;
269-
},
270-
[projects, workspaceMetadata, workspaceRecency]
271-
);
207+
const sortedWorkspacesByProject = useSortedWorkspacesByProject();
272208

273209
const handleNavigateWorkspace = useCallback(
274210
(direction: "next" | "prev") => {
@@ -367,27 +303,6 @@ function AppInner() {
367303
[startWorkspaceCreation]
368304
);
369305

370-
const getBranchesForProject = useCallback(
371-
async (projectPath: string): Promise<BranchListResult> => {
372-
const branchResult = await window.api.projects.listBranches(projectPath);
373-
const sanitizedBranches = Array.isArray(branchResult?.branches)
374-
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
375-
: [];
376-
377-
const recommended =
378-
typeof branchResult?.recommendedTrunk === "string" &&
379-
sanitizedBranches.includes(branchResult.recommendedTrunk)
380-
? branchResult.recommendedTrunk
381-
: (sanitizedBranches[0] ?? "");
382-
383-
return {
384-
branches: sanitizedBranches,
385-
recommendedTrunk: recommended,
386-
};
387-
},
388-
[]
389-
);
390-
391306
const selectWorkspaceFromPalette = useCallback(
392307
(selection: WorkspaceSelection) => {
393308
handleWorkspaceSwitch(selection);
@@ -406,8 +321,8 @@ function AppInner() {
406321
);
407322

408323
const addProjectFromPalette = useCallback(() => {
409-
setProjectCreateModalOpen(true);
410-
}, []);
324+
openProjectCreateModal();
325+
}, [openProjectCreateModal]);
411326

412327
const removeProjectFromPalette = useCallback(
413328
(path: string) => {
@@ -553,17 +468,10 @@ function AppInner() {
553468
<div className="bg-bg-dark mobile-layout flex h-screen overflow-hidden">
554469
<LeftSidebar
555470
onSelectWorkspace={handleWorkspaceSwitch}
556-
onAddProject={handleAddProjectCallback}
557-
onAddWorkspace={handleAddWorkspaceCallback}
558-
onRemoveProject={handleRemoveProjectCallback}
559471
lastReadTimestamps={lastReadTimestamps}
560472
onToggleUnread={onToggleUnread}
561473
collapsed={sidebarCollapsed}
562474
onToggleCollapsed={handleToggleSidebar}
563-
onGetSecrets={handleGetSecrets}
564-
onUpdateSecrets={handleUpdateSecrets}
565-
sortedWorkspacesByProject={sortedWorkspacesByProject}
566-
workspaceRecency={workspaceRecency}
567475
/>
568476
<div className="mobile-main-content flex min-w-0 flex-1 flex-col overflow-hidden">
569477
<div className="mobile-layout flex flex-1 overflow-hidden">
@@ -614,13 +522,13 @@ function AppInner() {
614522
telemetry.workspaceCreated(metadata.id);
615523

616524
// Clear pending state
617-
setPendingNewWorkspaceProject(null);
525+
clearPendingWorkspaceCreation();
618526
}}
619527
onCancel={
620528
pendingNewWorkspaceProject
621529
? () => {
622530
// User cancelled workspace creation - clear pending state
623-
setPendingNewWorkspaceProject(null);
531+
clearPendingWorkspaceCreation();
624532
}
625533
: undefined
626534
}
@@ -652,8 +560,8 @@ function AppInner() {
652560
})}
653561
/>
654562
<ProjectCreateModal
655-
isOpen={projectCreateModalOpen}
656-
onClose={() => setProjectCreateModalOpen(false)}
563+
isOpen={isProjectCreateModalOpen}
564+
onClose={closeProjectCreateModal}
657565
onSuccess={addProject}
658566
/>
659567
</div>

src/components/AppLoader.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { useState, useEffect } from "react";
22
import App from "../App";
33
import { LoadingScreen } from "./LoadingScreen";
4-
import { useProjectManagement } from "../hooks/useProjectManagement";
54
import { useWorkspaceManagement } from "../hooks/useWorkspaceManagement";
65
import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore";
76
import { useGitStatusStoreRaw } from "../stores/GitStatusStore";
87
import { usePersistedState } from "../hooks/usePersistedState";
98
import type { WorkspaceSelection } from "./ProjectSidebar";
109
import { AppProvider } from "../contexts/AppContext";
10+
import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext";
1111

1212
/**
1313
* AppLoader handles all initialization before rendering the main App:
@@ -20,20 +20,27 @@ import { AppProvider } from "../contexts/AppContext";
2020
* the need for conditional guards in effects.
2121
*/
2222
export function AppLoader() {
23+
return (
24+
<ProjectProvider>
25+
<AppLoaderInner />
26+
</ProjectProvider>
27+
);
28+
}
29+
30+
function AppLoaderInner() {
2331
// Workspace selection - restored from localStorage immediately
2432
const [selectedWorkspace, setSelectedWorkspace] = usePersistedState<WorkspaceSelection | null>(
2533
"selectedWorkspace",
2634
null
2735
);
2836

29-
// Load projects
30-
const projectManagement = useProjectManagement();
37+
const { refreshProjects } = useProjectContext();
3138

3239
// Load workspace metadata
3340
// Pass empty callbacks for now - App will provide the actual handlers
3441
const workspaceManagement = useWorkspaceManagement({
3542
selectedWorkspace,
36-
onProjectsUpdate: projectManagement.setProjects,
43+
onProjectsRefresh: refreshProjects,
3744
onSelectedWorkspaceUpdate: setSelectedWorkspace,
3845
});
3946

@@ -149,10 +156,6 @@ export function AppLoader() {
149156
// Render App with all initialized data via context
150157
return (
151158
<AppProvider
152-
projects={projectManagement.projects}
153-
setProjects={projectManagement.setProjects}
154-
addProject={projectManagement.addProject}
155-
removeProject={projectManagement.removeProject}
156159
workspaceMetadata={workspaceManagement.workspaceMetadata}
157160
setWorkspaceMetadata={workspaceManagement.setWorkspaceMetadata}
158161
createWorkspace={workspaceManagement.createWorkspace}

0 commit comments

Comments
 (0)