Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 64 additions & 9 deletions frontend/src/components/CodePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { Button } from "@/components/ui/button";
import { codeTreeIconForNode, shouldHideSystemEntry } from "@/components/codeTreeMeta";
import { cn } from "@/lib/utils";
import {
projectGitHistory,
projectGitPull,
projectGitStatus,
projectGitSync,
type GitHistoryCommit,
type GitPullResponse,
type GitStatusResponse,
} from "@/services/gitSync";
Expand Down Expand Up @@ -143,6 +145,8 @@ export const CodePane = ({
const [gitBaselinePresent, setGitBaselinePresent] = useState<boolean>(false);
const [gitConflictsPending, setGitConflictsPending] = useState<boolean>(false);
const [gitPullResult, setGitPullResult] = useState<GitPullResponse | null>(null);
const [latestCommit, setLatestCommit] = useState<GitHistoryCommit | null>(null);
const [latestCommitError, setLatestCommitError] = useState<string | null>(null);
const [conflict, setConflict] = useState<boolean>(false);

const { ref: treeWrapRef, height: treeHeight } = useElementHeight();
Expand All @@ -156,7 +160,7 @@ export const CodePane = ({
setTreeData(rootNodes);
}, [rootNodes]);

const refreshRoots = async () => {
const refreshRoots = useCallback(async () => {
setSaveStatus("");
setGitStatus("");
setConflict(false);
Expand All @@ -177,7 +181,15 @@ export const CodePane = ({
children: n.isDir ? [] : undefined,
}))
);
};
try {
const commits = await projectGitHistory(projectId, { limit: 1 });
setLatestCommit(commits[0] || null);
setLatestCommitError(null);
} catch {
setLatestCommit(null);
setLatestCommitError("Git activity unavailable");
}
}, [projectId]);

const loadChildren = useCallback(async (dirPath: string) => {
const { entries } = await sandboxLs(projectId, dirPath);
Expand All @@ -197,7 +209,7 @@ export const CodePane = ({
requestAnimationFrame(() => treeRef.current?.open("/"));
})
.catch(() => {});
}, [projectId, loadChildren]);
}, [refreshRoots, loadChildren]);

useEffect(() => {
// Re-read tree contents when file visibility filters change.
Expand Down Expand Up @@ -271,6 +283,17 @@ export const CodePane = ({
}, [projectId]);

const gitStatusReqIdRef = useRef(0);
const refreshLatestCommit = useCallback(async () => {
try {
const commits = await projectGitHistory(projectId, { limit: 1 });
setLatestCommit(commits[0] || null);
setLatestCommitError(null);
} catch {
setLatestCommit(null);
setLatestCommitError("Git activity unavailable");
}
}, [projectId]);

const refreshGitStatus = useCallback(async () => {
const reqId = ++gitStatusReqIdRef.current;
try {
Expand All @@ -290,6 +313,10 @@ export const CodePane = ({
void refreshGitStatus();
}, [refreshGitStatus]);

useEffect(() => {
void refreshLatestCommit();
}, [refreshLatestCommit]);

const startGitSync = useCallback(async (commitMessage?: string) => {
if (syncRunningRef.current) {
syncPendingRef.current = true;
Expand All @@ -309,6 +336,7 @@ export const CodePane = ({
setGitStatus(shaShort ? `Synced (${shaShort})` : "Synced");
setTimeout(() => setGitStatus(""), 2500);
void refreshGitStatus();
void refreshLatestCommit();
} catch (e: unknown) {
const status = asErrorWithStatus(e).status;
setGitStatus(
Expand All @@ -323,7 +351,7 @@ export const CodePane = ({
void startGitSync(msg);
}
}
}, [projectId, refreshGitStatus]);
}, [projectId, refreshGitStatus, refreshLatestCommit]);

const softRefreshTree = useCallback(() => {
// Keep editor state; only reset the lazy-loaded tree so users see new files.
Expand Down Expand Up @@ -356,6 +384,7 @@ export const CodePane = ({
await openFile(activePath);
}
void refreshGitStatus();
void refreshLatestCommit();
} catch (e: unknown) {
const status = asErrorWithStatus(e).status;
if (status === 409) {
Expand All @@ -364,7 +393,7 @@ export const CodePane = ({
}
setGitStatus(`Git update failed${status ? ` (HTTP ${status})` : ""}`);
}
}, [projectId, softRefreshTree, dirty, activePath, openFile, refreshGitStatus]);
}, [projectId, softRefreshTree, dirty, activePath, openFile, refreshGitStatus, refreshLatestCommit]);

useEffect(() => {
if (!dirty) return;
Expand Down Expand Up @@ -654,10 +683,36 @@ export const CodePane = ({
</Button>
</div>

<div style={{ fontSize: 12, opacity: 0.85, whiteSpace: "nowrap" }}>
{saveStatus}
{saveStatus && gitStatus ? " \u2022 " : ""}
{gitStatus}
<div className="text-right" style={{ fontSize: 12, opacity: 0.85 }}>
<div style={{ whiteSpace: "nowrap" }}>
{saveStatus}
{saveStatus && gitStatus ? " \u2022 " : ""}
{gitStatus}
</div>
<div className="text-[11px] text-muted-foreground">
{latestCommit ? (
<>
Latest: {latestCommit.author_name || latestCommit.author_email || "unknown"} ·{" "}
{latestCommit.short_sha}
{latestCommit.web_url ? (
<>
{" "}
<a
href={latestCommit.web_url}
target="_blank"
rel="noreferrer"
className="underline"
>
open
</a>
</>
) : null}
{" · see Production tab"}
</>
) : (
latestCommitError || "Latest: n/a"
)}
</div>
</div>
</div>

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/DatabasePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type NodeProps,
} from "@xyflow/react";
import { MoreHorizontal, X } from "lucide-react";
import { PaneLoading } from "@/components/PaneLoading";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -1585,7 +1586,7 @@ export const DatabasePane = ({
}, [draft, projectId, schemaVersion]);

if (loading || !draft) {
return <div style={{ padding: 16 }}>Loading database workspace...</div>;
return <PaneLoading message="Loading database workspace..." />;
}

const renderInspector = () => {
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/DesignPane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import type { DesignState } from "@/types/design";
import { CheckCircle2, Loader2 } from "lucide-react";
import { PaneLoading } from "@/components/PaneLoading";

type DesignPaneProps = {
state: DesignState | null;
Expand Down Expand Up @@ -50,9 +51,7 @@ export const DesignPane = ({
</div>

{loading ? (
<div className="text-xs text-muted-foreground">
This might take a few minutes.
</div>
<PaneLoading message="Generating design concepts..." />
) : null}

{error ? (
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/PaneLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Loader2 } from "lucide-react";

export function PaneLoading({ message = "Loading..." }: { message?: string }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
<div className="text-sm text-muted-foreground animate-pulse">{message}</div>
</div>
);
}
64 changes: 62 additions & 2 deletions frontend/src/components/ProductionPane.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertTriangle, CloudOff, Rocket } from "lucide-react";
import { PaneLoading } from "@/components/PaneLoading";

import { Button } from "@/components/ui/button";
import { projectGitHistory, type GitHistoryCommit } from "@/services/gitSync";
import {
productionCreateRelease,
productionDeployRelease,
Expand Down Expand Up @@ -35,6 +37,9 @@ export const ProductionPane = ({ projectId }: Props) => {
const [status, setStatus] = useState<ProductionStatusResponse | null>(null);
const [releases, setReleases] = useState<ProductionRelease[]>([]);
const [selectedReleaseId, setSelectedReleaseId] = useState<string>("");
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [history, setHistory] = useState<GitHistoryCommit[]>([]);

const refresh = useCallback(async () => {
if (!projectId) return;
Expand All @@ -57,9 +62,25 @@ export const ProductionPane = ({ projectId }: Props) => {
}
}, [projectId, selectedReleaseId]);

const refreshHistory = useCallback(async () => {
if (!projectId) return;
setHistoryLoading(true);
setHistoryError(null);
try {
const commits = await projectGitHistory(projectId, { limit: 30 });
setHistory(commits);
} catch (err) {
setHistoryError(apiErrorMessage(err, "Failed to load git history."));
setHistory([]);
} finally {
setHistoryLoading(false);
}
}, [projectId]);

useEffect(() => {
void refresh();
}, [refresh]);
void refreshHistory();
}, [refresh, refreshHistory]);

const onCreateRelease = useCallback(async () => {
setBusy(true);
Expand Down Expand Up @@ -137,7 +158,7 @@ export const ProductionPane = ({ projectId }: Props) => {
</details>
</div>
) : null}
{loading ? <div className="text-sm">Loading production data...</div> : null}
{loading ? <PaneLoading message="Loading production data..." /> : null}

<div className="border rounded-md p-3">
<div className="text-sm font-semibold">Live status</div>
Expand Down Expand Up @@ -182,6 +203,9 @@ export const ProductionPane = ({ projectId }: Props) => {
<Button variant="outline" onClick={() => void refresh()} disabled={busy}>
Refresh
</Button>
<Button variant="outline" onClick={() => void refreshHistory()} disabled={busy || historyLoading}>
Refresh Activity
</Button>
</div>
</div>

Expand Down Expand Up @@ -222,6 +246,42 @@ export const ProductionPane = ({ projectId }: Props) => {
) : null}
</div>

<div className="border rounded-md p-3 flex flex-col gap-2">
<div className="text-sm font-semibold">Git Activity</div>
{historyLoading ? (
<div className="text-xs text-muted-foreground">Loading commit history...</div>
) : historyError ? (
<div className="text-xs text-muted-foreground">
{historyError}
</div>
) : history.length === 0 ? (
<div className="text-xs text-muted-foreground">No commits yet.</div>
) : (
<div className="space-y-2">
{history.map((c) => (
<div key={c.sha} className="rounded-md border border-border bg-background/70 px-2 py-1.5">
<div className="text-sm font-medium truncate">{c.title || c.short_sha}</div>
<div className="text-[11px] text-muted-foreground">
{c.author_name || c.author_email || "unknown"} ·{" "}
{c.authored_date ? new Date(c.authored_date).toLocaleString() : "unknown time"} ·{" "}
{c.short_sha}
</div>
{c.web_url ? (
<a
href={c.web_url}
target="_blank"
rel="noreferrer"
className="text-[11px] underline"
>
Open commit
</a>
) : null}
</div>
))}
</div>
)}
</div>

{status?.deployment?.status === "failed" ? (
<div className="border border-amber-500/40 rounded-md p-3 bg-amber-500/5">
<div className="text-sm font-semibold flex items-center gap-2">
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/ProjectLockModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Button } from "@/components/ui/button";

type Props = {
open: boolean;
lockedByEmail?: string;
lockedAt?: string;
busy?: boolean;
onTakeOver: () => void;
onClose: () => void;
};

const formatWhen = (raw?: string): string => {
if (!raw) return "unknown time";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleString();
};

export const ProjectLockModal = ({
open,
lockedByEmail,
lockedAt,
busy = false,
onTakeOver,
onClose,
}: Props) => {
if (!open) return null;
return (
<div className="fixed inset-0 z-[40] bg-black/50 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-lg border border-border bg-background p-4">
<div className="text-base font-semibold">Project is currently locked</div>
<div className="mt-2 text-sm text-muted-foreground">
Active editor: <span className="font-medium">{lockedByEmail || "unknown"}</span>
<br />
Since: <span className="font-medium">{formatWhen(lockedAt)}</span>
</div>
<div className="mt-4 flex gap-2 justify-end">
<Button variant="outline" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button onClick={onTakeOver} disabled={busy}>
{busy ? "Taking over..." : "Take Over"}
</Button>
</div>
</div>
</div>
);
};
Loading
Loading