Secret Scan / scan (push) Successful in 16s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 6m17s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A third Opus review pass confirmed round 2's fixes hold up, then found:
- The pending-cleanup record `remove_project` writes is fully durable
(fsync'd); the projects_store.remove() that follows it is a plain
fs::write with no fsync. A crash or power loss in that window — or that
store write failing outright, beyond what the previous round's in-process
rollback catches — leaves a record on disk naming a project
projects.json still lists as present. The very next startup retry would
then delete that project's container, snapshot image, and both volumes
(including the one holding the OAuth credential and every session
transcript) out from under a project the user still sees in the sidebar.
retry_pending_cleanup_logged now takes the ProjectsStore and refuses to
touch — clearing instead — any record whose project id still exists.
Also stopped swallowing the round-2 rollback's own failure.
- Resolving the container through find_existing_container instead of
project.container_id (round 2's stale-id fix) changed what drove
close_sessions_for_container in remove_project and rebuild_project_
container: sessions are now leaked when Docker is unreachable (nothing
resolves, so nothing closes, and the project record is gone a moment
later) and in the stale-id race itself (sessions were opened against the
container that actually exists, not the id find_existing_container
bypasses). Both functions now close sessions for the stored id
unconditionally, and again for the resolved id if it differs.
- A pronoun-agreement bug in the no-retry removal toast ("remove them
manually" for a single leftover) that was fixed one line above for verb
agreement but not for the pronoun.
Also closed the test gaps the review named: the pending-cleanup
corrupt-record aside-move had no test, the Reset toast's leftover copy
was inline and untested (extracted to lib/resetOutcome.ts, mirroring
components/projects/home/removalReport.ts, with unit tests), and nothing
asserted rebuild()'s success path maps outcome.project into the list
rather than the whole outcome.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { useCallback, useState } from "react";
|
|
import { save } from "@tauri-apps/plugin-dialog";
|
|
import type { Project } from "../lib/types";
|
|
import * as commands from "../lib/tauri-commands";
|
|
import { formatBytes } from "../lib/formatBytes";
|
|
import { describeResetLeftovers, resetLeftoverPronoun } from "../lib/resetOutcome";
|
|
import { useAppState } from "../store/appState";
|
|
import { useProjects } from "./useProjects";
|
|
import { useTerminal } from "./useTerminal";
|
|
|
|
/**
|
|
* Lifecycle + terminal actions for one project, shared by the sidebar row and
|
|
* Project Home so both surfaces behave identically. Failures raise a toast
|
|
* (with expandable detail) rather than a blocking modal or a 12px card line.
|
|
*/
|
|
export function useProjectActions(project: Project) {
|
|
const { start, stop, rebuild } = useProjects();
|
|
const { open: openTerminal, sendInput } = useTerminal();
|
|
const pushToast = useAppState((s) => s.pushToast);
|
|
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
|
const [busy, setBusy] = useState(false);
|
|
const [backingUp, setBackingUp] = useState(false);
|
|
|
|
const fail = useCallback(
|
|
(message: string, error: unknown) => {
|
|
pushToast({ kind: "error", message, detail: String(error) });
|
|
},
|
|
[pushToast],
|
|
);
|
|
|
|
const run = useCallback(
|
|
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
|
setBusy(true);
|
|
setContainerProgress(project.id, null);
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
fail(`${label} failed for “${project.name}”`, e);
|
|
return undefined;
|
|
} finally {
|
|
setContainerProgress(project.id, null);
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[fail, project.id, project.name, setContainerProgress],
|
|
);
|
|
|
|
const handleStart = useCallback(
|
|
() => run("Start", () => start(project.id)),
|
|
[run, start, project.id],
|
|
);
|
|
|
|
const handleStop = useCallback(
|
|
() => run("Stop", () => stop(project.id)),
|
|
[run, stop, project.id],
|
|
);
|
|
|
|
const handleReset = useCallback(
|
|
() =>
|
|
run("Reset", async () => {
|
|
const outcome = await rebuild(project.id);
|
|
if (outcome.leftover_image || outcome.leftover_volumes.length > 0) {
|
|
// Not "run `docker volume rm`" — by the time this renders, the new
|
|
// container this same call just started already has the leftover
|
|
// volume mounted, so that command would just hit the same 409
|
|
// Reset did. Stopping the project first is what actually frees it.
|
|
pushToast({
|
|
kind: "error",
|
|
message: `Reset for “${project.name}” did not fully clean up`,
|
|
detail: `Triple-C could not remove ${describeResetLeftovers(outcome)} from before the reset, so \
|
|
the new container may still be built from, or contain, old data. Stop the project, then try \
|
|
Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
|
});
|
|
}
|
|
return outcome;
|
|
}),
|
|
[run, rebuild, project.id, project.name, pushToast],
|
|
);
|
|
|
|
const openClaudeTerminal = useCallback(async () => {
|
|
try {
|
|
return await openTerminal(project.id, project.name);
|
|
} catch (e) {
|
|
fail(`Could not open a Claude terminal for “${project.name}”`, e);
|
|
return null;
|
|
}
|
|
}, [openTerminal, project.id, project.name, fail]);
|
|
|
|
const openShell = useCallback(async () => {
|
|
try {
|
|
return await openTerminal(project.id, project.name, "bash");
|
|
} catch (e) {
|
|
fail(`Could not open a shell for “${project.name}”`, e);
|
|
return null;
|
|
}
|
|
}, [openTerminal, project.id, project.name, fail]);
|
|
|
|
/**
|
|
* Open a shell tab and type `command` into it. Used for [Resume] and for the
|
|
* capability drawer's "Manage in terminal" — the backend has no
|
|
* run-a-command entry point, so we drive the shell we just opened.
|
|
*/
|
|
const openTerminalWithCommand = useCallback(
|
|
async (command: string, sessionLabel?: string) => {
|
|
try {
|
|
const sessionId = await openTerminal(
|
|
project.id,
|
|
project.name,
|
|
"bash",
|
|
sessionLabel,
|
|
);
|
|
// Give the login shell a moment to draw its prompt before typing.
|
|
setTimeout(() => {
|
|
sendInput(sessionId, `${command}\n`).catch((e) =>
|
|
fail("Could not send the command to the terminal", e),
|
|
);
|
|
}, 700);
|
|
return sessionId;
|
|
} catch (e) {
|
|
fail(`Could not open a terminal for “${project.name}”`, e);
|
|
return null;
|
|
}
|
|
},
|
|
[openTerminal, sendInput, project.id, project.name, fail],
|
|
);
|
|
|
|
const handleBackup = useCallback(async () => {
|
|
if (!project.container_id) {
|
|
pushToast({
|
|
kind: "error",
|
|
message: "Start the project at least once before backing up.",
|
|
});
|
|
return;
|
|
}
|
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
|
try {
|
|
const hostPath = await save({
|
|
defaultPath: `${safeName}-backup-${stamp}.tar.gz`,
|
|
filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }],
|
|
});
|
|
if (!hostPath) return;
|
|
setBackingUp(true);
|
|
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
|
// `binary` matches what the host's file browser will say about the
|
|
// tarball this just wrote. The unit is part of the formatted string, so
|
|
// there is no separate " MB" to append — and unlike the inline
|
|
// `toFixed(1)` this replaced, a multi-gigabyte backup no longer reports
|
|
// itself as a five-digit number of megabytes.
|
|
const size = formatBytes(bytes, { binary: true });
|
|
pushToast({
|
|
kind: "success",
|
|
message: `Backup saved (${size}).`,
|
|
detail:
|
|
"Includes Claude config — may contain API keys. Keep the archive private.",
|
|
});
|
|
} catch (e) {
|
|
fail("Backup failed", e);
|
|
} finally {
|
|
setBackingUp(false);
|
|
}
|
|
}, [project.container_id, project.id, project.name, pushToast, fail]);
|
|
|
|
return {
|
|
busy,
|
|
backingUp,
|
|
handleStart,
|
|
handleStop,
|
|
handleReset,
|
|
handleBackup,
|
|
openClaudeTerminal,
|
|
openShell,
|
|
openTerminalWithCommand,
|
|
};
|
|
}
|