Add "Backup" action to download a project's /workspace as .tar.gz

Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.

Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.

Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:48:04 -07:00
parent 424ab04ca8
commit d07dcdfea9
4 changed files with 150 additions and 2 deletions
+34 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { open, save } from "@tauri-apps/plugin-dialog";
import * as commands from "../../lib/tauri-commands";
import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects";
@@ -37,6 +38,7 @@ export default function ProjectCard({ project }: Props) {
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
const [operationCompleted, setOperationCompleted] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [backingUp, setBackingUp] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(project.name);
const isSelected = selectedProjectId === project.id;
@@ -177,6 +179,31 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleBackup = async () => {
if (!project.container_id) {
setError("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);
setError(null);
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
const mb = (bytes / (1024 * 1024)).toFixed(1);
setProgressMsg(`Backup saved (${mb} MB)`);
} catch (e) {
setError(String(e));
} finally {
setBackingUp(false);
}
};
const closeModal = () => {
setActiveOperation(null);
setOperationCompleted(false);
@@ -484,6 +511,11 @@ export default function ProjectCard({ project }: Props) {
{isStopped ? (
<>
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
<ActionButton
onClick={handleBackup}
disabled={loading || backingUp || !project.container_id}
label={backingUp ? "Backing up…" : "Backup"}
/>
<ActionButton
onClick={async () => {
setLoading(true);
@@ -504,6 +536,7 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
<ActionButton onClick={handleBackup} disabled={loading || backingUp} label={backingUp ? "Backing up…" : "Backup"} />
</>
) : (
<>