Compare commits
1 Commits
v0.1.75-wi
...
v0.1.76-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ddc705925 |
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
operation: "starting" | "stopping" | "resetting";
|
||||
progressMsg: string | null;
|
||||
error: string | null;
|
||||
completed: boolean;
|
||||
onForceStop: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const operationLabels: Record<string, string> = {
|
||||
starting: "Starting",
|
||||
stopping: "Stopping",
|
||||
resetting: "Resetting",
|
||||
};
|
||||
|
||||
export default function ContainerProgressModal({
|
||||
projectName,
|
||||
operation,
|
||||
progressMsg,
|
||||
error,
|
||||
completed,
|
||||
onForceStop,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-close on success after 800ms
|
||||
useEffect(() => {
|
||||
if (completed && !error) {
|
||||
const timer = setTimeout(onClose, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [completed, error, onClose]);
|
||||
|
||||
// Escape to close (only when completed or error)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && (completed || error)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [completed, error, onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current && (completed || error)) onClose();
|
||||
},
|
||||
[completed, error, onClose],
|
||||
);
|
||||
|
||||
const inProgress = !completed && !error;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
||||
<h3 className="text-sm font-semibold mb-4">
|
||||
{operationLabels[operation]} “{projectName}”
|
||||
</h3>
|
||||
|
||||
{/* Spinner / checkmark / error icon */}
|
||||
<div className="flex justify-center mb-3">
|
||||
{error ? (
|
||||
<span className="text-3xl text-[var(--error)]">✕</span>
|
||||
) : completed ? (
|
||||
<span className="text-3xl text-[var(--success)]">✓</span>
|
||||
) : (
|
||||
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress message */}
|
||||
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
||||
{error
|
||||
? <span className="text-[var(--error)]">{error}</span>
|
||||
: completed
|
||||
? "Done!"
|
||||
: progressMsg ?? `${operationLabels[operation]}...`}
|
||||
</p>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{inProgress && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
||||
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
||||
>
|
||||
Force Stop
|
||||
</button>
|
||||
)}
|
||||
{(completed || error) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAppState } from "../../store/appState";
|
||||
import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -25,6 +26,8 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
@@ -79,16 +82,25 @@ export default function ProjectCard({ project }: Props) {
|
||||
return () => { unlisten.then((f) => f()); };
|
||||
}, [project.id]);
|
||||
|
||||
// Clear progress when status settles
|
||||
// Mark operation completed when status settles
|
||||
useEffect(() => {
|
||||
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
|
||||
setProgressMsg(null);
|
||||
if (activeOperation) {
|
||||
setOperationCompleted(true);
|
||||
}
|
||||
// Clear progress if no modal is managing it
|
||||
if (!activeOperation) {
|
||||
setProgressMsg(null);
|
||||
}
|
||||
}
|
||||
}, [project.status]);
|
||||
}, [project.status, activeOperation]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgressMsg(null);
|
||||
setOperationCompleted(false);
|
||||
setActiveOperation("starting");
|
||||
try {
|
||||
await start(project.id);
|
||||
} catch (e) {
|
||||
@@ -101,6 +113,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
const handleStop = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgressMsg(null);
|
||||
setOperationCompleted(false);
|
||||
setActiveOperation("stopping");
|
||||
try {
|
||||
await stop(project.id);
|
||||
} catch (e) {
|
||||
@@ -118,6 +133,21 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceStop = async () => {
|
||||
try {
|
||||
await stop(project.id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setActiveOperation(null);
|
||||
setOperationCompleted(false);
|
||||
setProgressMsg(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const defaultBedrockConfig: BedrockConfig = {
|
||||
auth_method: "static_credentials",
|
||||
aws_region: "us-east-1",
|
||||
@@ -324,6 +354,10 @@ export default function ProjectCard({ project }: Props) {
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgressMsg(null);
|
||||
setOperationCompleted(false);
|
||||
setActiveOperation("resetting");
|
||||
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}}
|
||||
@@ -747,6 +781,18 @@ export default function ProjectCard({ project }: Props) {
|
||||
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeOperation && (
|
||||
<ContainerProgressModal
|
||||
projectName={project.name}
|
||||
operation={activeOperation}
|
||||
progressMsg={progressMsg}
|
||||
error={error}
|
||||
completed={operationCompleted}
|
||||
onForceStop={handleForceStop}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -86,6 +87,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// Track scroll position to show "Jump to Current" button
|
||||
const scrollDisposable = term.onScroll(() => {
|
||||
const buf = term.buffer.active;
|
||||
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||
});
|
||||
|
||||
// Handle image paste: intercept paste events with image data,
|
||||
// upload to the container, and inject the file path into terminal input.
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
@@ -164,6 +171,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
@@ -231,6 +239,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}, [detectedUrl]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
termRef.current?.scrollToBottom();
|
||||
setIsAtBottom(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
@@ -251,6 +264,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={handleScrollToBottom}
|
||||
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
|
||||
>
|
||||
Jump to Current ↓
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
|
||||
@@ -50,31 +50,45 @@ export function useProjects() {
|
||||
[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],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
const updated = await commands.startProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "stopping");
|
||||
await commands.stopProjectContainer(id);
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
},
|
||||
[setProjects],
|
||||
[setProjects, setOptimisticStatus],
|
||||
);
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user