Two more pieces of drift from the same merges, both invisible to `tsc`. **A refused Start/Stop/Reset strands the row.** `start`, `stop` and `rebuild` paint an optimistic "starting"/"stopping" so a click moves the row at once. That was safe while the only way these could fail was after the backend had begun changing things. `fix/sec`'s per-project lock ended that: all three now take the lock and are refused *before* any state changes, and `stop` could not fail this way at all before — it took no exclusion. So the optimistic paint has nothing to become, `isTransitioning` disables both Start and Stop, and the only thing that clears it is `reconcileProjectStatuses`, which runs once from `App.tsx` when Docker first appears. Clicking Stop during a compaction left the project unusable until the app was restarted. `withOptimisticStatus` re-reads the authoritative list when the command throws, and falls back to the status that was on screen if even that call fails — two failures in a row must not land on the one state there is no way out of. The error is rethrown unchanged, so the toast is unaffected. Five of the six new tests fail against the previous code; the sixth pins that the optimistic paint still happens on the way in. **Six secrets are typed as if they arrive, and they never do.** `git_token`, the four Bedrock credentials and `OpenAiCompatibleConfig .api_key` are `#[serde(skip_serializing)]` in Rust, so the key is absent from every project the backend returns — reading one gives `undefined`, not the `null` the type promised. Every current reader happens to use `?? ""`, so nothing is broken today; a single `=== null` would have been a branch that silently never ran. They are optional now, which makes that a compile error, and documented as write-only, which is what they are. 669 frontend tests pass, `tsc --noEmit` clean, `npm run build` green. Nothing under `src-tauri/` touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useAppState } from "../store/appState";
|
|
import * as commands from "../lib/tauri-commands";
|
|
import type { ProjectPath, ProjectStatus } from "../lib/types";
|
|
|
|
export function useProjects() {
|
|
const {
|
|
projects,
|
|
selectedProjectId,
|
|
setProjects,
|
|
setSelectedProject,
|
|
updateProjectInList,
|
|
removeProjectFromList,
|
|
} = useAppState(
|
|
useShallow(s => ({
|
|
projects: s.projects,
|
|
selectedProjectId: s.selectedProjectId,
|
|
setProjects: s.setProjects,
|
|
setSelectedProject: s.setSelectedProject,
|
|
updateProjectInList: s.updateProjectInList,
|
|
removeProjectFromList: s.removeProjectFromList,
|
|
}))
|
|
);
|
|
|
|
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
|
|
|
|
const refresh = useCallback(async () => {
|
|
const list = await commands.listProjects();
|
|
setProjects(list);
|
|
}, [setProjects]);
|
|
|
|
const add = useCallback(
|
|
async (name: string, paths: ProjectPath[]) => {
|
|
const project = await commands.addProject(name, paths);
|
|
// Refresh from backend to avoid stale closure issues
|
|
const list = await commands.listProjects();
|
|
setProjects(list);
|
|
setSelectedProject(project.id);
|
|
return project;
|
|
},
|
|
[setProjects, setSelectedProject],
|
|
);
|
|
|
|
const remove = useCallback(
|
|
async (id: string) => {
|
|
await commands.removeProject(id);
|
|
removeProjectFromList(id);
|
|
},
|
|
[removeProjectFromList],
|
|
);
|
|
|
|
const setOptimisticStatus = useCallback(
|
|
(id: string, status: "starting" | "stopping") => {
|
|
const { projects } = useAppState.getState();
|
|
const project = projects.find((p) => p.id === id);
|
|
if (project) {
|
|
updateProjectInList({ ...project, status });
|
|
}
|
|
},
|
|
[updateProjectInList],
|
|
);
|
|
|
|
/**
|
|
* Paint the optimistic status, run the command, and **put the status back if
|
|
* the command never happened.**
|
|
*
|
|
* The optimistic write exists so a click moves the row immediately. It used
|
|
* to be safe to leave in place on failure, because the only way these three
|
|
* commands could fail was after the backend had already started changing
|
|
* things — so a stale "starting" was at worst premature.
|
|
*
|
|
* That stopped being true when every lifecycle command started taking the
|
|
* per-project lock and failing fast: a compaction, a reset or another start
|
|
* holding the project now refuses `start`, `stop` **and** `rebuild` before
|
|
* one byte of state changes. `stop` in particular could not fail this way at
|
|
* all before — it took no exclusion. The optimistic paint then has nothing to
|
|
* become, and `isTransitioning` disables both Start and Stop, so the row is
|
|
* stuck: the only thing that clears it is `reconcileProjectStatuses`, which
|
|
* runs once, from `App.tsx`, when Docker first appears. A restart.
|
|
*
|
|
* Re-reading the list is preferred over restoring what was on screen, because
|
|
* a refusal is not the only way these throw — a start that dies half-way
|
|
* really has changed the world, and `listProjects` is the thing that knows.
|
|
* The captured status is only the fallback for when that call fails too:
|
|
* leaving the row transitioning is the one outcome the user cannot get out
|
|
* of, so it must not be what a second failure lands on.
|
|
*/
|
|
const withOptimisticStatus = useCallback(
|
|
async <T,>(
|
|
id: string,
|
|
status: "starting" | "stopping",
|
|
run: () => Promise<T>,
|
|
): Promise<T> => {
|
|
const previous: ProjectStatus | null =
|
|
useAppState.getState().projects.find((p) => p.id === id)?.status ?? null;
|
|
setOptimisticStatus(id, status);
|
|
try {
|
|
return await run();
|
|
} catch (e) {
|
|
try {
|
|
setProjects(await commands.listProjects());
|
|
} catch {
|
|
const project = useAppState.getState().projects.find((p) => p.id === id);
|
|
if (project && previous) updateProjectInList({ ...project, status: previous });
|
|
}
|
|
// Rethrown unchanged: `useProjectActions` is what turns this into a
|
|
// toast, and it must still see the original failure.
|
|
throw e;
|
|
}
|
|
},
|
|
[setOptimisticStatus, setProjects, updateProjectInList],
|
|
);
|
|
|
|
const start = useCallback(
|
|
(id: string) =>
|
|
withOptimisticStatus(id, "starting", async () => {
|
|
const updated = await commands.startProjectContainer(id);
|
|
updateProjectInList(updated);
|
|
return updated;
|
|
}),
|
|
[updateProjectInList, withOptimisticStatus],
|
|
);
|
|
|
|
const stop = useCallback(
|
|
(id: string) =>
|
|
withOptimisticStatus(id, "stopping", async () => {
|
|
await commands.stopProjectContainer(id);
|
|
const list = await commands.listProjects();
|
|
setProjects(list);
|
|
}),
|
|
[setProjects, withOptimisticStatus],
|
|
);
|
|
|
|
const rebuild = useCallback(
|
|
(id: string) =>
|
|
withOptimisticStatus(id, "starting", async () => {
|
|
const updated = await commands.rebuildProjectContainer(id);
|
|
updateProjectInList(updated);
|
|
return updated;
|
|
}),
|
|
[updateProjectInList, withOptimisticStatus],
|
|
);
|
|
|
|
const update = useCallback(
|
|
async (project: Parameters<typeof commands.updateProject>[0]) => {
|
|
const updated = await commands.updateProject(project);
|
|
updateProjectInList(updated);
|
|
return updated;
|
|
},
|
|
[updateProjectInList],
|
|
);
|
|
|
|
return {
|
|
projects,
|
|
selectedProject,
|
|
selectedProjectId,
|
|
setSelectedProject,
|
|
refresh,
|
|
add,
|
|
remove,
|
|
start,
|
|
stop,
|
|
rebuild,
|
|
update,
|
|
};
|
|
}
|