UNPKG

datajunction-ui

Version:
1,206 lines (1,162 loc) 53.5 kB
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" />