datajunction-ui
Version:
DataJunction UI
1,206 lines (1,162 loc) • 53.5 kB
JSX
import { useContext, useEffect, useState, useRef } from 'react';
import {
searchNamespaces,
topLevelNamespaces,
} from '../pages/NamespacePage/namespaceOptions';
import DJClientContext from '../providers/djclient';
import {
secondaryButtonStyle as buttonStyle,
primaryButtonStyle,
onSecondaryHover,
onSecondaryOut,
} from './buttonStyles';
import {
GitSettingsModal,
CreateBranchModal,
SyncToGitModal,
CreatePRModal,
DeleteBranchModal,
} from './git';
import { detectShape } from './git/gitShape';
import GitMenu from './GitMenu';
export default function NamespaceHeader({
namespace,
children,
onGitConfigLoaded,
onReadOnlyChange,
namespaceOptions,
currentNamespace,
}) {
const djClient = useContext(DJClientContext).DataJunctionAPI;
const [sources, setSources] = useState(null);
const [recentDeployments, setRecentDeployments] = useState([]);
const [deploymentsDropdownOpen, setDeploymentsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
// Git config state
const [gitConfig, setGitConfig] = useState(null);
const [gitConfigLoading, setGitConfigLoading] = useState(true);
const [parentGitConfig, setParentGitConfig] = useState(null);
const [existingPR, setExistingPR] = useState(null);
const [prLoading, setPrLoading] = useState(false);
// Branch switcher state
const [branches, setBranches] = useState([]);
const [branchDropdownOpen, setBranchDropdownOpen] = useState(false);
const branchDropdownRef = useRef(null);
// Namespace switcher state (opt-in: only active when namespaceOptions is provided)
const [nsSwitcherOpen, setNsSwitcherOpen] = useState(false);
const [nsQuery, setNsQuery] = useState('');
const nsSwitcherRef = useRef(null);
// Modal states
const [showGitSettings, setShowGitSettings] = useState(false);
const [showCreateBranch, setShowCreateBranch] = useState(false);
const [showSyncToGit, setShowSyncToGit] = useState(false);
const [showCreatePR, setShowCreatePR] = useState(false);
const [showDeleteBranch, setShowDeleteBranch] = useState(false);
useEffect(() => {
// Reset loading state when namespace changes
setGitConfigLoading(true);
setPrLoading(false);
setExistingPR(null);
const fetchData = async () => {
if (!namespace) {
if (onGitConfigLoaded) onGitConfigLoaded(null);
setGitConfigLoading(false);
return;
}
if (namespace) {
// Fetch deployment sources
try {
const data = await djClient.namespaceSources(namespace);
setSources(data);
try {
const deployments = await djClient.listDeployments(namespace, 5);
setRecentDeployments(deployments || []);
} catch (err) {
console.error('Failed to fetch deployments:', err);
setRecentDeployments([]);
}
} catch (e) {
// Silently fail - badge just won't show
}
// Fetch git config
try {
const config = await djClient.getNamespaceGitConfig(namespace);
setGitConfig(config);
if (onGitConfigLoaded) {
onGitConfigLoaded(config);
}
// If this namespace is inside a branch (or IS the branch), fetch
// parent/branches/PR.
const branchNs = config?.branch_namespace;
if (branchNs) {
// The branch namespace itself carries the parent_namespace FK to
// the git root. If this is a subnamespace, fetch the branch's
// config to get that FK; otherwise the current config already
// has it.
const branchConfig =
branchNs === namespace
? config
: await djClient
.getNamespaceGitConfig(branchNs)
.catch(() => null);
const gitRootNs = branchConfig?.parent_namespace;
if (gitRootNs) {
try {
const parentConfig = await djClient.getNamespaceGitConfig(
gitRootNs,
);
setParentGitConfig(parentConfig);
} catch (e) {
console.error('Failed to fetch parent git config:', e);
}
try {
const branchList = await djClient.getNamespaceBranches(
gitRootNs,
);
setBranches(branchList || []);
} catch (e) {
console.error('Failed to fetch branches:', e);
}
}
// Check for existing PR scoped to the branch, not the current
// (sub)namespace — a branch has at most one PR regardless of
// which subnamespace page the user is on.
setPrLoading(true);
try {
const pr = await djClient.getPullRequest(branchNs);
setExistingPR(pr);
} catch (e) {
// No PR or error - that's fine
setExistingPR(null);
} finally {
setPrLoading(false);
}
}
} catch (e) {
// Git config not available
setGitConfig(null);
if (onGitConfigLoaded) {
onGitConfigLoaded(null);
}
} finally {
setGitConfigLoading(false);
}
}
};
fetchData();
}, [djClient, namespace]);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = event => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setDeploymentsDropdownOpen(false);
}
if (
branchDropdownRef.current &&
!branchDropdownRef.current.contains(event.target)
) {
setBranchDropdownOpen(false);
}
if (
nsSwitcherRef.current &&
!nsSwitcherRef.current.contains(event.target)
) {
setNsSwitcherOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const namespaceParts = namespace ? namespace.split('.') : [];
// ``branch_namespace`` is the branch this namespace belongs to (equals the
// namespace when called against the branch itself, the ancestor branch when
// called against a descendant).
// isInBranch = "this lives under a branch" (used to show branch-scoped git
// controls on subnamespaces too).
const branchNamespace = gitConfig?.branch_namespace || null;
const isInBranch = !!branchNamespace;
const branchScopeNamespace = branchNamespace || namespace;
// Descendants inherit github_repo_path via cascade, so compare against
// git_root_namespace to know if this namespace IS the git root.
const isGitRoot =
gitConfig?.github_repo_path && gitConfig?.git_root_namespace === namespace;
// Handlers for git operations
const handleSaveGitConfig = async config => {
const result = await djClient.updateNamespaceGitConfig(
gitConfigOwnerNamespace,
config,
);
if (!result?._error) {
if (isBranch) setParentGitConfig(prev => ({ ...prev, ...result }));
else setGitConfig(result);
}
return result;
};
const handleRemoveGitConfig = async () => {
if (
!window.confirm(
'Remove this git config binding? If this namespace is still being deployed from git by CI, it will remain read-only. This does not delete any nodes or repo files.',
)
)
return;
const result = await djClient.deleteNamespaceGitConfig(
gitConfigOwnerNamespace,
);
if (!result?._error) {
if (isBranch) setParentGitConfig(null);
else setGitConfig(null);
}
};
// Branches are always created under the git root, forking from its default
// branch — whether we're viewing the root itself or one of its branches (on a
// branch page the root is carried by git_root_namespace / parentGitConfig).
const gitRootNamespace = gitConfig?.git_root_namespace || namespace;
const rootDefaultBranch = isGitRoot
? gitConfig?.default_branch
: parentGitConfig?.default_branch;
const canCreateBranch = !!(gitRootNamespace && rootDefaultBranch);
const handleCreateBranch = async branchName => {
return await djClient.createBranch(gitRootNamespace, branchName);
};
// Sync/PR operations always target the whole branch namespace, even
// when invoked from a subnamespace page.
const handleSyncToGit = async commitMessage => {
return await djClient.syncNamespaceToGit(
branchScopeNamespace,
commitMessage,
);
};
const handleCreatePR = async (title, body, onProgress) => {
// First sync changes to git using PR title as commit message
if (onProgress) onProgress('syncing');
const syncResult = await djClient.syncNamespaceToGit(
branchScopeNamespace,
title,
);
if (syncResult?._error) {
return syncResult;
}
// Then create the PR
if (onProgress) onProgress('creating');
const result = await djClient.createPullRequest(
branchScopeNamespace,
title,
body,
);
if (result && !result._error) {
setExistingPR(result);
}
return result;
};
const handleDeleteBranch = async deleteGitBranch => {
return await djClient.deleteBranch(
gitConfig.parent_namespace,
namespace,
deleteGitBranch,
);
};
// Git state derivations
const gitShape = detectShape(gitConfig || {});
// Read-only follows the branch, not the resolved config's flat/root shape.
// A sub-namespace inside a feature branch resolves as `flat` (it inherits
// github_repo_path + git_branch from the branch but carries no
// parent_namespace of its own), so the shape test alone wrongly locks
// editable feature branches — the actual regression here. Instead: content
// on the repo's default branch, on a git root, or in a 1:1 flat namespace is
// read-only; a non-default (feature) branch and its sub-namespaces are
// editable. `rootDefaultBranch` is resolved above from the git root's config
// (parentGitConfig), which has finished loading by the time the verdict fires
// below — setGitConfigLoading(false) runs after that fetch.
const onSubBranch = !!branchNamespace && branchNamespace !== gitRootNamespace;
const isDefaultBranch = onSubBranch
? gitConfig?.git_branch === rootDefaultBranch
: gitShape === 'root' || gitShape === 'flat';
const isReadOnly = !!gitConfig?.git_only || isDefaultBranch;
const isGitDeployed =
!!sources &&
sources.total_deployments > 0 &&
sources.primary_source?.type === 'git';
// A non-default (feature) branch and its sub-namespaces stay editable even
// when git-deployed — editing then syncing back is the point. The
// git-deployed lock only applies elsewhere (default branch, 1:1 flat, git
// root, or an unrecognized-but-git-deployed shape), which `isReadOnly`
// already covers for the recognized cases.
const isFeatureBranch = onSubBranch && !isDefaultBranch;
const isGitManaged = isReadOnly || (isGitDeployed && !isFeatureBranch);
// Repo config is owned by the git root. On a branch, edits target the root
// (its config is parentGitConfig — branches sit one level below the root); a
// flat or plain namespace owns its own config. This keeps flat namespaces
// (e.g. magnesium.tech, no parent) editing themselves.
const isBranch = gitShape === 'branch';
const gitConfigOwnerNamespace = isBranch ? gitRootNamespace : namespace;
const gitConfigOwnerConfig = isBranch ? parentGitConfig : gitConfig;
// Report the read-only verdict only once it's actually known (git config +
// sources loaded). Firing a premature `false` on mount makes consumers flash
// editable UI (e.g. the node edit form) before the real verdict arrives.
useEffect(() => {
if (onReadOnlyChange && !gitConfigLoading) onReadOnlyChange(!!isGitManaged);
}, [isGitManaged, gitConfigLoading, onReadOnlyChange]);
const viewInGitUrl = () => {
const repo = gitConfig?.github_repo_path;
if (!repo) return null;
const branch = gitConfig?.git_branch || gitConfig?.default_branch || 'main';
const path = gitConfig?.git_path
? `/${gitConfig.git_path.replace(/^\/|\/$/g, '')}`
: '';
return `https://github.netflix.net/${repo}/tree/${branch}${path}`;
};
return (
<>
<style>
{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}
</style>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// Wrap so the action buttons drop below the breadcrumb on narrow
// screens instead of overflowing / being clipped off the right edge.
flexWrap: 'wrap',
gap: '8px',
padding: '12px 12px 12px 20px',
marginBottom: '16px',
borderTop: '1px solid #e2e8f0',
borderBottom: '1px solid #e2e8f0',
background: '#ffffff',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<a href="/" style={{ display: 'flex', alignItems: 'center' }}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z" />
</svg>
</a>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
fill="#6c757d"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
/>
</svg>
{namespace ? (
namespaceParts.map((part, index, arr) => {
const isLast = index === arr.length - 1;
const cumulative = arr.slice(0, index + 1).join('.');
const href = `/namespaces/${cumulative}`;
// The branch crumb (the segment whose cumulative path equals the
// branch namespace) becomes the branch switcher — at ANY depth, not
// only when the URL is exactly the branch root (so it stays available
// after drilling into sub-namespaces below the branch).
const isBranchCrumb =
isInBranch && cumulative === branchNamespace;
return (
<span
key={index}
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
{/* The branch crumb becomes the branch switcher. The first
segment becomes a searchable namespace switcher when
namespaceOptions is provided (opt-in). */}
{isBranchCrumb ? (
<div
ref={branchDropdownRef}
style={{ position: 'relative' }}
>
<button
onClick={() => setBranchDropdownOpen(o => !o)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '0',
background: 'none',
border: 'none',
fontWeight: '400',
fontSize: 'inherit',
color: '#1e293b',
cursor: 'pointer',
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#64748b"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
{part}
<span style={{ fontSize: '8px', color: '#94a3b8' }}>
{branchDropdownOpen ? '▲' : '▼'}
</span>
</button>
{branchDropdownOpen && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
backgroundColor: 'white',
border: '1px solid #e2e8f0',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
zIndex: 1000,
minWidth: '180px',
overflow: 'hidden',
}}
>
<div
style={{
padding: '8px 12px 6px',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#94a3b8',
borderBottom: '1px solid #f1f5f9',
}}
>
<a
href={`/namespaces/${gitConfig.parent_namespace}`}
style={{
color: '#94a3b8',
textDecoration: 'none',
}}
onClick={() => setBranchDropdownOpen(false)}
>
{gitConfig.parent_namespace}
</a>
</div>
{branches.length === 0 ? (
<div
style={{
padding: '10px 12px',
fontSize: '12px',
color: '#94a3b8',
}}
>
No branches found
</div>
) : (
branches.map(b => {
const isCurrent = b.namespace === namespace;
return (
<a
key={b.namespace}
href={`/namespaces/${b.namespace}`}
onClick={() => setBranchDropdownOpen(false)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
fontSize: '13px',
color: isCurrent ? '#1e40af' : '#1e293b',
backgroundColor: isCurrent
? '#eff6ff'
: 'white',
textDecoration: 'none',
borderBottom: '1px solid #f8fafc',
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
}}
>
{isCurrent && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0 }}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '180px',
}}
title={b.git_branch || b.namespace}
>
{b.git_branch || b.namespace}
</span>
</span>
<span
style={{
fontSize: '11px',
color: '#94a3b8',
flexShrink: 0,
marginLeft: '8px',
}}
>
{b.num_nodes} nodes
</span>
</a>
);
})
)}
{canCreateBranch && (
<button
onClick={() => {
setBranchDropdownOpen(false);
setShowCreateBranch(true);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
padding: '10px 12px',
fontSize: '13px',
fontWeight: 500,
color: '#2563eb',
background: 'none',
border: 'none',
borderTop: '1px solid #f1f5f9',
cursor: 'pointer',
textAlign: 'left',
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New branch
</button>
)}
</div>
)}
</div>
) : index === 0 && namespaceOptions?.length > 0 ? (
<div ref={nsSwitcherRef} style={{ position: 'relative' }}>
<button
onClick={() => {
setNsSwitcherOpen(o => !o);
setNsQuery('');
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '0',
background: 'none',
border: 'none',
fontWeight: '400',
fontSize: 'inherit',
color: '#1e293b',
cursor: 'pointer',
}}
>
{part}
<span style={{ fontSize: '8px', color: '#94a3b8' }}>
{nsSwitcherOpen ? '▲' : '▼'}
</span>
</button>
{nsSwitcherOpen && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
backgroundColor: 'white',
border: '1px solid #e2e8f0',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
zIndex: 1000,
minWidth: '220px',
overflow: 'hidden',
}}
>
<div style={{ padding: '8px' }}>
<input
type="text"
value={nsQuery}
onChange={e => setNsQuery(e.target.value)}
placeholder="Find a namespace…"
autoFocus
style={{
width: '100%',
padding: '6px 8px',
fontSize: '12px',
border: '1px solid #e2e8f0',
borderRadius: '4px',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
<div
style={{ maxHeight: '280px', overflowY: 'auto' }}
>
{(() => {
// Default list: top-level namespaces only (the
// switcher is for jumping between top-level areas;
// deeper levels are reached by drilling folders).
// Typing searches across ALL depths.
const results = nsQuery.trim()
? searchNamespaces(namespaceOptions, nsQuery)
: topLevelNamespaces(namespaceOptions);
if (results.length === 0) {
return (
<div
style={{
padding: '8px 12px',
fontSize: '12px',
color: '#94a3b8',
}}
>
No namespaces match.
</div>
);
}
return results.map(path => {
const isCurrent = path === currentNamespace;
return (
<a
key={path}
href={`/namespaces/${path}`}
onClick={() => setNsSwitcherOpen(false)}
style={{
display: 'block',
padding: '7px 12px',
fontSize: '13px',
color: isCurrent ? '#1e40af' : '#1e293b',
backgroundColor: isCurrent
? '#eff6ff'
: 'white',
textDecoration: 'none',
borderBottom: '1px solid #f8fafc',
fontWeight: isCurrent ? 600 : 400,
}}
>
{path}
</a>
);
});
})()}
</div>
</div>
)}
</div>
) : (
<a
href={href}
style={{
fontWeight: '400',
color: '#1e293b',
textDecoration: 'none',
}}
>
{part}
</a>
)}
{!isLast && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
fill="#94a3b8"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
/>
</svg>
)}
</span>
);
})
) : (
<span style={{ fontWeight: '600', color: '#1e293b' }}>
All Namespaces
</span>
)}
{/* Git-only (read-only) indicator */}
{isGitManaged && (
<span
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
backgroundColor: '#fef3c7',
borderRadius: '12px',
fontSize: '11px',
color: '#92400e',
marginLeft: '4px',
}}
title="This namespace is git-only. Changes must be made via git and deployed."
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Read-only
</span>
)}
{/* Local deployment badge + dropdown (non-git deploys only;
git deployments are surfaced in the status strip below) */}
{sources &&
sources.total_deployments > 0 &&
sources.primary_source?.type !== 'git' && (
<div
style={{ position: 'relative', marginLeft: '8px' }}
ref={dropdownRef}
>
<button
onClick={() =>
setDeploymentsDropdownOpen(!deploymentsDropdownOpen)
}
style={{
height: '32px',
padding: '0 12px',
fontSize: '12px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#ffffff',
color: '#0b3d91',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
}}
>
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="7" r="4" />
<path d="M5.5 21a6.5 6.5 0 0 1 13 0Z" />
</svg>
Local Deploy
</>
<span style={{ fontSize: '8px' }}>
{deploymentsDropdownOpen ? '▲' : '▼'}
</span>
</button>
{deploymentsDropdownOpen && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
padding: '12px',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 1000,
minWidth: 'max-content',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
fontWeight: 600,
color: '#0b3d91',
marginBottom: '12px',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="7" r="4" />
<path d="M5.5 21a6.5 6.5 0 0 1 13 0Z" />
</svg>
{recentDeployments?.[0]?.created_by
? `Local deploys by ${recentDeployments[0].created_by}`
: 'Local/adhoc deployments'}
</div>
{/* Separator */}
<div
style={{
height: '1px',
backgroundColor: '#e2e8f0',
marginBottom: '8px',
}}
/>
{/* Recent deployments list */}
{recentDeployments?.length > 0 ? (
recentDeployments.map((d, idx) => {
const isGit = d.source?.type === 'git';
const statusColor =
d.status === 'success'
? '#22c55e'
: d.status === 'failed'
? '#ef4444'
: '#94a3b8';
const commitUrl =
isGit && d.source?.repository && d.source?.commit_sha
? `${
d.source.repository.startsWith('http')
? d.source.repository
: `https://${d.source.repository}`
}/commit/${d.source.commit_sha}`
: null;
const detail = isGit
? d.source?.branch || 'main'
: d.source?.reason || d.source?.hostname || 'adhoc';
const shortSha = d.source?.commit_sha?.slice(0, 7);
return (
<div
key={`${d.uuid}-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: '18px 1fr auto',
alignItems: 'center',
gap: '8px',
padding: '6px 0',
borderBottom:
idx === recentDeployments.length - 1
? 'none'
: '1px solid #f1f5f9',
fontSize: '12px',
}}
>
{/* Status dot */}
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: statusColor,
}}
title={d.status}
/>
{/* User + detail */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
}}
>
<span
style={{
fontWeight: 500,
color: '#0f172a',
whiteSpace: 'nowrap',
}}
>
{d.created_by || 'unknown'}
</span>
<span style={{ color: '#cbd5e1' }}>—</span>
{isGit ? (
<>
<span
style={{
color: '#64748b',
whiteSpace: 'nowrap',
}}
>
{detail}
</span>
{shortSha && (
<>
<span style={{ color: '#cbd5e1' }}>
@
</span>
{commitUrl ? (
<a
href={commitUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontFamily: 'monospace',
fontSize: '11px',
color: '#3b82f6',
textDecoration: 'none',
}}
>
{shortSha}
</a>
) : (
<span
style={{
fontFamily: 'monospace',
fontSize: '11px',
color: '#64748b',
}}
>
{shortSha}
</span>
)}
</>
)}
</>
) : (
<span
style={{
color: '#64748b',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{detail}
</span>
)}
</div>
{/* Timestamp */}
<span
style={{
color: '#94a3b8',
fontSize: '11px',
whiteSpace: 'nowrap',
}}
>
{new Date(d.created_at).toLocaleDateString()}
</span>
</div>
);
})
) : (
<div style={{ color: '#94a3b8', fontSize: '12px' }}>
No recent deployments
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Right side: git actions + children */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
// Let the buttons wrap among themselves, staying right-aligned, when
// they can't fit on one line. marginLeft:auto keeps them right-
// aligned even after they wrap onto their own row below the crumb.
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginLeft: 'auto',
}}
>
{/* Git controls — one primary action per state + a Git ▾ menu for the
rest. isGitManaged (read-only) is the master switch and overrides
structural gitShape. */}
{!gitConfigLoading &&
namespace &&
(() => {
const repoBranch =
gitConfig?.git_branch || gitConfig?.default_branch;
const editableBranch = gitShape === 'branch' && !isGitManaged;
const gitUrl = viewInGitUrl();
// Read-only: fork to propose a change; everything else in the menu.
if (isGitManaged) {
return (
<>
{canCreateBranch && (
<button
style={primaryButtonStyle}
onClick={() => setShowCreateBranch(true)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Branch
</button>
)}
<GitMenu
repoPath={gitConfig?.github_repo_path}
branch={repoBranch}
viewInGitUrl={gitUrl}
onOpenSettings={() => setShowGitSettings(true)}
/>
</>
);
}
// Editable working branch: the edit -> ship loop inline.
if (editableBranch) {
return (
<>
<button
style={buttonStyle}
onClick={() => setShowSyncToGit(true)}
onMouseOver={onSecondaryHover}
onMouseOut={onSecondaryOut}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9m-9 9a9 9 0 0 1 9-9" />
</svg>
Sync to Git
</button>
{prLoading ? (
<button
style={{
...buttonStyle,
cursor: 'default',
opacity: 0.6,
}}
disabled
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ animation: 'spin 1s linear infinite' }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Checking PR...
</button>
) : existingPR ? (
<a
href={existingPR.pr_url}
target="_blank"
rel="noopener noreferrer"
style={{
...primaryButtonStyle,
textDecoration: 'none',
backgroundColor: '#16a34a',
borderColor: '#16a34a',
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
<line x1="6" y1="9" x2="6" y2="21" />
</svg>
View PR #{existingPR.pr_number}
</a>
) : (
<button
style={primaryButtonStyle}
onClick={() => setShowCreatePR(true)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />