feat(FN-1865): complete remotes tab UX pass with two-column layout
- Update tests for two-column layout structure - Fix test selectors to use within() for scoped queries - Add mocks for fetchGitRemotesDetailed in button tests - Use remote-sync-card and remote-detail-card testids Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
@@ -837,6 +837,7 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast, projectId }:
|
||||
onPush={handlePush}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
copyToClipboard={copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1638,7 +1639,7 @@ function StashesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/** Enhanced Remotes panel with full remote management capabilities */
|
||||
/** Enhanced Remotes panel with two-column layout */
|
||||
function RemotesPanel({
|
||||
status,
|
||||
remoteLoading,
|
||||
@@ -1648,6 +1649,7 @@ function RemotesPanel({
|
||||
onPush,
|
||||
addToast,
|
||||
projectId,
|
||||
copyToClipboard,
|
||||
}: {
|
||||
status: GitStatus | null;
|
||||
remoteLoading: string | null;
|
||||
@@ -1657,7 +1659,17 @@ function RemotesPanel({
|
||||
onPush: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
copyToClipboard: (text: string, label?: string) => void;
|
||||
}) {
|
||||
/** Extract hostname from remote URL */
|
||||
const getHostFromUrl = (url: string): string => {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url.replace(/^git@/, "").split(":")[0] || url;
|
||||
}
|
||||
};
|
||||
|
||||
const [remotes, setRemotes] = useState<GitRemoteDetailed[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [remoteActionLoading, setRemoteActionLoading] = useState<string | null>(null);
|
||||
@@ -1678,6 +1690,9 @@ function RemotesPanel({
|
||||
const [loadingRemoteCommits, setLoadingRemoteCommits] = useState(false);
|
||||
const [remoteCommitsError, setRemoteCommitsError] = useState<string | null>(null);
|
||||
|
||||
// Derived state for selected remote
|
||||
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
|
||||
|
||||
// Inline commit diff expansion (one per list context)
|
||||
const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null);
|
||||
const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||
@@ -1887,272 +1902,255 @@ function RemotesPanel({
|
||||
|
||||
return (
|
||||
<div className="gm-panel gm-remotes-panel" data-testid="remotes-panel">
|
||||
<div className="gm-panel-header">
|
||||
<h4>Remote Management</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
>
|
||||
{showAddForm ? <X size={14} /> : <Plus size={14} />}
|
||||
{showAddForm ? "Cancel" : "Add Remote"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Remote Form */}
|
||||
{showAddForm && (
|
||||
<form className="gm-remote-form" onSubmit={handleAddRemote}>
|
||||
<div className="gm-form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Remote name (e.g., origin)"
|
||||
value={newRemoteName}
|
||||
onChange={(e) => setNewRemoteName(e.target.value)}
|
||||
disabled={remoteActionLoading === "add"}
|
||||
className="gm-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Repository URL"
|
||||
value={newRemoteUrl}
|
||||
onChange={(e) => setNewRemoteUrl(e.target.value)}
|
||||
disabled={remoteActionLoading === "add"}
|
||||
className="gm-input gm-input-url"
|
||||
/>
|
||||
{/* Two-column layout */}
|
||||
<div className="gm-remotes-layout">
|
||||
{/* ── Left Column: Remote Selector ── */}
|
||||
<div className="gm-remote-selector" data-testid="remote-selector">
|
||||
<div className="gm-remote-selector-header">
|
||||
<span className="gm-remote-selector-title">Remotes</span>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || remoteActionLoading === "add"}
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title={showAddForm ? "Cancel" : "Add Remote"}
|
||||
>
|
||||
{remoteActionLoading === "add" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<Plus size={14} />
|
||||
)}
|
||||
Add
|
||||
{showAddForm ? <X size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Remote Operations (Fetch/Pull/Push) */}
|
||||
<div className="gm-remote-operations">
|
||||
{/* Commits to Push */}
|
||||
{status && status.ahead > 0 && (
|
||||
<div className="gm-commits-to-push" data-testid="commits-to-push">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<ArrowUp size={14} />
|
||||
Commits to Push ({status.ahead})
|
||||
</h5>
|
||||
{/* Add Remote Form - collapsible */}
|
||||
{showAddForm && (
|
||||
<form className="gm-remote-form" onSubmit={handleAddRemote}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Remote name"
|
||||
value={newRemoteName}
|
||||
onChange={(e) => setNewRemoteName(e.target.value)}
|
||||
disabled={remoteActionLoading === "add"}
|
||||
className="gm-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Repository URL"
|
||||
value={newRemoteUrl}
|
||||
onChange={(e) => setNewRemoteUrl(e.target.value)}
|
||||
disabled={remoteActionLoading === "add"}
|
||||
className="gm-input gm-input-url"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || remoteActionLoading === "add"}
|
||||
>
|
||||
{remoteActionLoading === "add" ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Plus size={12} />
|
||||
)}
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Remote list */}
|
||||
{loading ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading...
|
||||
</div>
|
||||
{loadingAhead ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading...
|
||||
) : remotes.length === 0 ? (
|
||||
<div className="gm-empty">No remotes</div>
|
||||
) : (
|
||||
remotes.map((remote) => (
|
||||
<div
|
||||
key={remote.name}
|
||||
className={`gm-remote-selector-item${selectedRemote === remote.name ? " selected" : ""}`}
|
||||
onClick={() => setSelectedRemote(remote.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="gm-remote-selector-info">
|
||||
<span className="gm-remote-selector-name">
|
||||
{remote.name}
|
||||
{remote.name === "origin" && (
|
||||
<span className="gm-remote-default-badge">default</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="gm-remote-selector-host">
|
||||
{getHostFromUrl(remote.fetchUrl)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleRemoveRemote(remote.name); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Remove remote"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : aheadCommits.length > 0 ? (
|
||||
<div className="gm-ahead-commits-list" data-testid="ahead-commits-list">
|
||||
{aheadCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
|
||||
<div
|
||||
className="gm-commit-item-compact gm-commit-clickable"
|
||||
onClick={() => handleCompactCommitClick(commit.hash, "ahead")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Click to view diff"
|
||||
>
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="gm-commit-expand-icon">
|
||||
{expandedAheadCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: Detail Panel ── */}
|
||||
<div className="gm-remote-detail" data-testid="remote-detail-panel">
|
||||
{selectedRemote && selectedRemoteData ? (
|
||||
<>
|
||||
{/* Sync Status Card */}
|
||||
<div className="gm-remote-sync-card" data-testid="remote-sync-card">
|
||||
<div className="gm-remote-sync-card-header">
|
||||
<span className="gm-remote-sync-card-title">{selectedRemote}</span>
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="gm-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
<div className="gm-remote-indicator ahead">
|
||||
<ArrowUp size={14} />
|
||||
{status.ahead} to push
|
||||
</div>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<div className="gm-remote-indicator behind">
|
||||
<ArrowDown size={14} />
|
||||
{status.behind} to pull
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expandedAheadCommit === commit.hash && (
|
||||
<div className="gm-commit-diff gm-commit-diff-compact">
|
||||
{loadingAheadCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : aheadCommitDiff ? (
|
||||
<>
|
||||
{commit.message && (
|
||||
<div className="gm-commit-message-full">{commit.message}</div>
|
||||
)}
|
||||
{aheadCommitDiff.stat && <pre className="gm-diff-stat">{aheadCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{aheadCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<div className="gm-remote-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onFetch}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "fetch" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Fetch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPull}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "pull" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitPullRequest size={14} />
|
||||
)}
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPush}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "push" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Detail Card */}
|
||||
<div className="gm-remote-detail-card" data-testid="remote-detail-card">
|
||||
<div className="gm-remote-detail-urls">
|
||||
{/* Fetch URL */}
|
||||
<div className="gm-remote-detail-url-row">
|
||||
<span className="gm-url-label">Fetch:</span>
|
||||
{editingRemote === `url-${selectedRemote}` ? (
|
||||
<div className="gm-remote-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editUrlValue}
|
||||
onChange={(e) => setEditUrlValue(e.target.value)}
|
||||
className="gm-input"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleUpdateUrl(selectedRemote)}
|
||||
disabled={remoteActionLoading === `url-${selectedRemote}`}
|
||||
>
|
||||
{remoteActionLoading === `url-${selectedRemote}` ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Check size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => { setEditingRemote(null); setEditUrlValue(""); }}
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="gm-url-value" title={selectedRemoteData.fetchUrl}>
|
||||
{selectedRemoteData.fetchUrl}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => copyToClipboard(selectedRemoteData.fetchUrl, "fetch URL")}
|
||||
title="Copy fetch URL"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => startEditingUrl(selectedRemoteData)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit remote URL"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-empty">
|
||||
No ahead commits found (may need to fetch first)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ahead/Behind indicators */}
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="gm-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
<div className="gm-remote-indicator ahead">
|
||||
<ArrowUp size={16} />
|
||||
{status.ahead} commit(s) to push
|
||||
</div>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<div className="gm-remote-indicator behind">
|
||||
<ArrowDown size={16} />
|
||||
{status.behind} commit(s) to pull
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Push URL */}
|
||||
{selectedRemoteData.pushUrl && selectedRemoteData.pushUrl !== selectedRemoteData.fetchUrl && (
|
||||
<div className="gm-remote-detail-url-row">
|
||||
<span className="gm-url-label">Push:</span>
|
||||
<span className="gm-url-value" title={selectedRemoteData.pushUrl}>
|
||||
{selectedRemoteData.pushUrl}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => copyToClipboard(selectedRemoteData.pushUrl!, "push URL")}
|
||||
title="Copy push URL"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="gm-remote-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onFetch}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "fetch" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Fetch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPull}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "pull" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitPullRequest size={14} />
|
||||
)}
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onPush}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
>
|
||||
{remoteLoading === "push" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remotes List */}
|
||||
<div className="gm-remote-list">
|
||||
{loading ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
Loading remotes...
|
||||
</div>
|
||||
) : remotes.length === 0 ? (
|
||||
<div className="gm-empty">No remotes configured</div>
|
||||
) : (
|
||||
remotes.map((remote) => (
|
||||
<div
|
||||
key={remote.name}
|
||||
className={`gm-remote-item${selectedRemote === remote.name ? " selected" : ""}`}
|
||||
onClick={() => setSelectedRemote(remote.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="gm-remote-info">
|
||||
{editingRemote === `name-${remote.name}` ? (
|
||||
<div className="gm-remote-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
className="gm-input"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleRenameRemote(remote.name)}
|
||||
disabled={remoteActionLoading === `rename-${remote.name}`}
|
||||
>
|
||||
{remoteActionLoading === `rename-${remote.name}` ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Check size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingRemote(null);
|
||||
setEditNameValue("");
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-name-row">
|
||||
<span className="gm-remote-name">{remote.name}</span>
|
||||
<button
|
||||
className="btn btn-icon gm-remote-edit-btn"
|
||||
onClick={(e) => { e.stopPropagation(); startEditingName(remote); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit remote name"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gm-remote-urls">
|
||||
<div className="gm-remote-url">
|
||||
<span className="gm-url-label">Fetch:</span>
|
||||
<span className="gm-url-value" title={remote.fetchUrl}>
|
||||
{remote.fetchUrl}
|
||||
</span>
|
||||
</div>
|
||||
{editingRemote === `url-${remote.name}` ? (
|
||||
<div className="gm-remote-edit gm-url-edit">
|
||||
{/* Name editing */}
|
||||
<div className="gm-remote-detail-name-row">
|
||||
{editingRemote === `name-${selectedRemote}` ? (
|
||||
<div className="gm-remote-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editUrlValue}
|
||||
onChange={(e) => setEditUrlValue(e.target.value)}
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
className="gm-input"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleUpdateUrl(remote.name)}
|
||||
disabled={remoteActionLoading === `url-${remote.name}`}
|
||||
onClick={() => handleRenameRemote(selectedRemote)}
|
||||
disabled={remoteActionLoading === `rename-${selectedRemote}`}
|
||||
>
|
||||
{remoteActionLoading === `url-${remote.name}` ? (
|
||||
{remoteActionLoading === `rename-${selectedRemote}` ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Check size={12} />
|
||||
@@ -2160,135 +2158,187 @@ function RemotesPanel({
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingRemote(null);
|
||||
setEditUrlValue("");
|
||||
}}
|
||||
onClick={() => { setEditingRemote(null); setEditNameValue(""); }}
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-url gm-push-url">
|
||||
<span className="gm-url-label">Push:</span>
|
||||
<span className="gm-url-value" title={remote.pushUrl}>
|
||||
{remote.pushUrl || remote.fetchUrl}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-icon gm-remote-edit-btn"
|
||||
onClick={(e) => { e.stopPropagation(); startEditingUrl(remote); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit remote URL"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => startEditingName(selectedRemoteData)}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit remote name"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-remote-actions-inline">
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={(e) => { e.stopPropagation(); handleRemoveRemote(remote.name); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Remove remote"
|
||||
>
|
||||
{remoteActionLoading === `remove-${remote.name}` ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Remote Commits */}
|
||||
{selectedRemote && (
|
||||
<div className="gm-remote-commits-section" data-testid="remote-commits-section">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<Radio size={14} />
|
||||
Recent commits on {selectedRemote}
|
||||
</h5>
|
||||
</div>
|
||||
{loadingRemoteCommits ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading commits...
|
||||
</div>
|
||||
) : remoteCommitsError ? (
|
||||
<div className="gm-error">
|
||||
<AlertCircle size={14} />
|
||||
{remoteCommitsError}
|
||||
</div>
|
||||
) : remoteCommits.length === 0 ? (
|
||||
<div className="gm-empty">
|
||||
No commits found on {selectedRemote}. Try fetching first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-commits-list" data-testid="remote-commits-list">
|
||||
{remoteCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
|
||||
<div
|
||||
className="gm-commit-item-compact gm-commit-clickable"
|
||||
onClick={() => handleCompactCommitClick(commit.hash, "remote")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Click to view diff"
|
||||
>
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="gm-commit-expand-icon">
|
||||
{expandedRemoteCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
{/* Commits to Push Section */}
|
||||
{status && status.ahead > 0 && (
|
||||
<div className="gm-remote-section" data-testid="commits-to-push">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<ArrowUp size={14} />
|
||||
Commits to Push ({status.ahead})
|
||||
</h5>
|
||||
</div>
|
||||
{expandedRemoteCommit === commit.hash && (
|
||||
<div className="gm-commit-diff gm-commit-diff-compact">
|
||||
{loadingRemoteCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : remoteCommitDiff ? (
|
||||
<>
|
||||
{commit.message && (
|
||||
<div className="gm-commit-message-full">{commit.message}</div>
|
||||
{loadingAhead ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : aheadCommits.length > 0 ? (
|
||||
<div className="gm-ahead-commits-list" data-testid="ahead-commits-list">
|
||||
{aheadCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
|
||||
<div
|
||||
className="gm-commit-item-compact gm-commit-clickable"
|
||||
onClick={() => handleCompactCommitClick(commit.hash, "ahead")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Click to view diff"
|
||||
>
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="gm-commit-expand-icon">
|
||||
{expandedAheadCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
{expandedAheadCommit === commit.hash && (
|
||||
<div className="gm-commit-diff gm-commit-diff-compact">
|
||||
{loadingAheadCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : aheadCommitDiff ? (
|
||||
<>
|
||||
{commit.message && (
|
||||
<div className="gm-commit-message-full">{commit.message}</div>
|
||||
)}
|
||||
{aheadCommitDiff.stat && <pre className="gm-diff-stat">{aheadCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{aheadCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{remoteCommitDiff.stat && <pre className="gm-diff-stat">{remoteCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{remoteCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-empty">
|
||||
No ahead commits found (may need to fetch first)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Recent Remote Commits Section */}
|
||||
<div className="gm-remote-section" data-testid="remote-commits-section">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<Radio size={14} />
|
||||
Recent commits on {selectedRemote}
|
||||
</h5>
|
||||
</div>
|
||||
{loadingRemoteCommits ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading commits...
|
||||
</div>
|
||||
) : remoteCommitsError ? (
|
||||
<div className="gm-error">
|
||||
<AlertCircle size={14} />
|
||||
{remoteCommitsError}
|
||||
</div>
|
||||
) : remoteCommits.length === 0 ? (
|
||||
<div className="gm-empty">
|
||||
No commits found on {selectedRemote}. Try fetching first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-commits-list" data-testid="remote-commits-list">
|
||||
{remoteCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
|
||||
<div
|
||||
className="gm-commit-item-compact gm-commit-clickable"
|
||||
onClick={() => handleCompactCommitClick(commit.hash, "remote")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Click to view diff"
|
||||
>
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="gm-commit-expand-icon">
|
||||
{expandedRemoteCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
{expandedRemoteCommit === commit.hash && (
|
||||
<div className="gm-commit-diff gm-commit-diff-compact">
|
||||
{loadingRemoteCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : remoteCommitDiff ? (
|
||||
<>
|
||||
{commit.message && (
|
||||
<div className="gm-commit-message-full">{commit.message}</div>
|
||||
)}
|
||||
{remoteCommitDiff.stat && <pre className="gm-diff-stat">{remoteCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{remoteCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-empty">
|
||||
Select a remote to view details
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRemoteResult && (
|
||||
<div className="gm-remote-result">
|
||||
{lastRemoteResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRemoteResult && (
|
||||
<div className="gm-remote-result">
|
||||
{lastRemoteResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitManagerModal } from "../GitManagerModal";
|
||||
import type { Task } from "@fusion/core";
|
||||
@@ -1025,27 +1025,36 @@ describe("GitManagerModal", () => {
|
||||
|
||||
// ── Remotes Panel ──────────────────────────────────────────
|
||||
|
||||
it("shows remote operation buttons", async () => {
|
||||
it("shows remote operation buttons in sync card", async () => {
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /pull/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /push/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const syncCard = screen.getByTestId("remote-sync-card");
|
||||
expect(syncCard.textContent).toContain("Fetch");
|
||||
expect(syncCard.textContent).toContain("Pull");
|
||||
expect(syncCard.textContent).toContain("Push");
|
||||
});
|
||||
|
||||
it("calls fetchRemote when Fetch button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/test/repo.git", pushUrl: "" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
// Wait for sync card and scope button search within it to avoid matching the Refresh button
|
||||
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||
const fetchButton = within(syncCard).getByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1060,7 +1069,9 @@ describe("GitManagerModal", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const pullButton = await screen.findByRole("button", { name: /pull/i });
|
||||
// Wait for sync card and scope button search within it
|
||||
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||
const pullButton = within(syncCard).getByRole("button", { name: /pull/i });
|
||||
await user.click(pullButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1075,7 +1086,9 @@ describe("GitManagerModal", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const pushButton = await screen.findByRole("button", { name: /push/i });
|
||||
// Wait for sync card and scope button search within it
|
||||
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||
const pushButton = within(syncCard).getByRole("button", { name: /push/i });
|
||||
await user.click(pushButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1085,6 +1098,9 @@ describe("GitManagerModal", () => {
|
||||
|
||||
it("shows error toast when fetch fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/test/repo.git", pushUrl: "" },
|
||||
]);
|
||||
(fetchRemote as any).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(
|
||||
@@ -1092,7 +1108,9 @@ describe("GitManagerModal", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
// Wait for sync card and scope button search within it
|
||||
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||
const fetchButton = within(syncCard).getByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1100,7 +1118,7 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows ahead/behind remote indicators", async () => {
|
||||
it("shows ahead/behind indicators in sync card", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
@@ -1115,9 +1133,12 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2 commit(s) to push")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 commit(s) to pull")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const syncCard = screen.getByTestId("remote-sync-card");
|
||||
expect(syncCard.textContent).toContain("2 to push");
|
||||
expect(syncCard.textContent).toContain("3 to pull");
|
||||
});
|
||||
|
||||
// ── Remote Management Tests ───────────────────────────────────
|
||||
@@ -1148,7 +1169,7 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading remotes...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1163,29 +1184,29 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add Remote")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Add Remote"));
|
||||
await user.click(screen.getByTitle("Add Remote"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Remote name (e.g., origin)");
|
||||
const nameInput = screen.getByPlaceholderText("Remote name");
|
||||
const urlInput = screen.getByPlaceholderText("Repository URL");
|
||||
|
||||
await user.type(nameInput, "origin");
|
||||
await user.type(nameInput, "newremote");
|
||||
await user.type(urlInput, "https://github.com/test/repo.git");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^add$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expectLatestCallStartsWith(addGitRemote as any, "origin", "https://github.com/test/repo.git");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'origin' added successfully", "success");
|
||||
expectLatestCallStartsWith(addGitRemote as any, "newremote", "https://github.com/test/repo.git");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'newremote' added successfully", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when adding remote fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
|
||||
(addGitRemote as any).mockRejectedValue(new Error("Remote 'origin' already exists"));
|
||||
(addGitRemote as any).mockRejectedValue(new Error("Remote 'newremote' already exists"));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
@@ -1193,21 +1214,21 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add Remote")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Add Remote"));
|
||||
await user.click(screen.getByTitle("Add Remote"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Remote name (e.g., origin)");
|
||||
const nameInput = screen.getByPlaceholderText("Remote name");
|
||||
const urlInput = screen.getByPlaceholderText("Repository URL");
|
||||
|
||||
await user.type(nameInput, "origin");
|
||||
await user.type(nameInput, "newremote");
|
||||
await user.type(urlInput, "https://github.com/test/repo.git");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^add$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'origin' already exists", "error");
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Remote 'newremote' already exists", "error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1238,7 +1259,7 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renames a remote", async () => {
|
||||
it("renames a remote in detail panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
|
||||
@@ -1251,13 +1272,12 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const renameButton = screen.getByTitle("Edit remote name");
|
||||
await user.click(renameButton);
|
||||
|
||||
// The input should appear - find it by its autoFocus or by looking for an input
|
||||
const nameInput = screen.getByDisplayValue("origin");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "upstream");
|
||||
@@ -1272,7 +1292,7 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates remote URL", async () => {
|
||||
it("updates remote URL in detail panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://old-url.com/repo.git", pushUrl: "https://old-url.com/repo.git" },
|
||||
@@ -1285,7 +1305,7 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByTitle("Edit remote URL");
|
||||
@@ -1307,7 +1327,7 @@ describe("GitManagerModal", () => {
|
||||
|
||||
// ── Edit Affordance Regression ──────────────────────────────────
|
||||
|
||||
it("shows editor-style icon for remote name edit action", async () => {
|
||||
it("shows editor-style icon for remote name edit action in detail panel", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
|
||||
]);
|
||||
@@ -1318,23 +1338,17 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The name edit button should have an accessible title distinguishing it as a name edit
|
||||
const nameEditBtn = screen.getByTitle("Edit remote name");
|
||||
expect(nameEditBtn).toBeInTheDocument();
|
||||
expect(nameEditBtn).toBeVisible();
|
||||
|
||||
// Verify it does not propagate clicks to row selection
|
||||
const remoteItem = nameEditBtn.closest(".gm-remote-item");
|
||||
const selectedBefore = remoteItem?.classList.contains("selected");
|
||||
fireEvent.click(nameEditBtn);
|
||||
// After clicking, the editing state activates (input appears), selection should not change unexpectedly
|
||||
expect(screen.getByDisplayValue("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows editor-style icon for remote URL edit action", async () => {
|
||||
it("shows editor-style icon for remote URL edit action in detail panel", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
|
||||
]);
|
||||
@@ -1345,17 +1359,13 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The URL edit button should have an accessible title distinguishing it as a URL edit
|
||||
const urlEditBtn = screen.getByTitle("Edit remote URL");
|
||||
expect(urlEditBtn).toBeInTheDocument();
|
||||
expect(urlEditBtn).toBeVisible();
|
||||
|
||||
// Verify it does not propagate clicks to row selection
|
||||
fireEvent.click(urlEditBtn);
|
||||
// After clicking, the URL editing state activates
|
||||
expect(screen.getByDisplayValue("https://github.com/a/b.git")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1369,25 +1379,26 @@ describe("GitManagerModal", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
// Wait for both cards to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both edit controls should be present and distinguishable by title
|
||||
const nameEditBtn = screen.getByTitle("Edit remote name");
|
||||
const urlEditBtn = screen.getByTitle("Edit remote URL");
|
||||
// Both edit buttons are in the detail card
|
||||
const detailCard = screen.getByTestId("remote-detail-card");
|
||||
const nameEditBtn = within(detailCard).getByTitle("Edit remote name");
|
||||
const urlEditBtn = within(detailCard).getByTitle("Edit remote URL");
|
||||
expect(nameEditBtn).not.toBe(urlEditBtn);
|
||||
|
||||
// Each enters the correct edit mode
|
||||
fireEvent.click(nameEditBtn);
|
||||
expect(screen.getByDisplayValue("origin")).toBeInTheDocument();
|
||||
|
||||
// Cancel name edit
|
||||
const cancelBtn = nameEditBtn.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm:not(.btn-primary)");
|
||||
// After clicking edit, the button is replaced with input + cancel button inside .gm-remote-edit
|
||||
const cancelBtn = screen.getByTitle("Cancel");
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
fireEvent.click(cancelBtn as HTMLButtonElement);
|
||||
fireEvent.click(cancelBtn);
|
||||
|
||||
// Now click URL edit
|
||||
fireEvent.click(urlEditBtn);
|
||||
expect(screen.getByDisplayValue("https://github.com/a/b.git")).toBeInTheDocument();
|
||||
});
|
||||
@@ -1405,14 +1416,12 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the name edit affordance
|
||||
const nameEditBtn = screen.getByTitle("Edit remote name");
|
||||
await user.click(nameEditBtn);
|
||||
|
||||
// Edit the name and save
|
||||
const nameInput = screen.getByDisplayValue("origin");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "upstream");
|
||||
@@ -1439,14 +1448,12 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the URL edit affordance
|
||||
const urlEditBtn = screen.getByTitle("Edit remote URL");
|
||||
await user.click(urlEditBtn);
|
||||
|
||||
// Edit the URL and save
|
||||
const urlInput = screen.getByDisplayValue("https://old-url.com/repo.git");
|
||||
await user.clear(urlInput);
|
||||
await user.type(urlInput, "https://new-url.com/repo.git");
|
||||
@@ -1460,37 +1467,6 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies mobile-friendly edit button class for touch targets", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both edit buttons should have the mobile-friendly class for adequate touch targets
|
||||
const nameEditBtn = screen.getByTitle("Edit remote name");
|
||||
const urlEditBtn = screen.getByTitle("Edit remote URL");
|
||||
|
||||
expect(nameEditBtn.classList.contains("gm-remote-edit-btn")).toBe(true);
|
||||
expect(urlEditBtn.classList.contains("gm-remote-edit-btn")).toBe(true);
|
||||
|
||||
// Both should be visible and have 16px icons
|
||||
expect(nameEditBtn).toBeVisible();
|
||||
expect(urlEditBtn).toBeVisible();
|
||||
|
||||
const nameSvg = nameEditBtn.querySelector("svg");
|
||||
const urlSvg = urlEditBtn.querySelector("svg");
|
||||
expect(nameSvg).toBeTruthy();
|
||||
expect(urlSvg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockRejectedValue(new Error("Failed to load remotes"));
|
||||
|
||||
@@ -1783,13 +1759,13 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No remotes configured")).toBeInTheDocument();
|
||||
expect(screen.getByText("No remotes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("remote-commits-section")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights selected remote in the list", async () => {
|
||||
it("highlights selected remote in selector", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
|
||||
{ name: "upstream", fetchUrl: "https://github.com/c/d.git", pushUrl: "https://github.com/c/d.git" },
|
||||
@@ -1800,11 +1776,18 @@ describe("GitManagerModal", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
// First remote (origin) should be auto-selected
|
||||
await waitFor(() => {
|
||||
const originItem = screen.getByText("origin").closest(".gm-remote-item");
|
||||
expect(originItem?.classList.contains("selected")).toBe(true);
|
||||
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const remoteSelector = screen.getByTestId("remote-selector");
|
||||
const selectorItems = remoteSelector.querySelectorAll(".gm-remote-selector-item");
|
||||
expect(selectorItems.length).toBe(2);
|
||||
expect(selectorItems[0].classList.contains("selected")).toBe(true);
|
||||
});
|
||||
|
||||
// ── Refresh Button ─────────────────────────────────────────
|
||||
@@ -2112,6 +2095,10 @@ describe("GitManagerModal", () => {
|
||||
|
||||
it("passes projectId to fetchRemote when Fetch is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Ensure remotes are available for selection
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
@@ -2123,7 +2110,11 @@ describe("GitManagerModal", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
// Wait for the sync card to appear (indicates remote is selected)
|
||||
const syncCard = await screen.findByTestId("remote-sync-card");
|
||||
|
||||
// Scope button search within the sync card to avoid matching the Refresh button
|
||||
const fetchButton = within(syncCard).getByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2212,12 +2203,12 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add Remote")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Add Remote")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Add Remote"));
|
||||
await user.click(screen.getByTitle("Add Remote"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Remote name (e.g., origin)");
|
||||
const nameInput = screen.getByPlaceholderText("Remote name");
|
||||
const urlInput = screen.getByPlaceholderText("Repository URL");
|
||||
|
||||
await user.type(nameInput, "newremote");
|
||||
@@ -2288,10 +2279,11 @@ describe("GitManagerModal", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const renameButton = screen.getByTitle("Edit remote name");
|
||||
const detailCard = screen.getByTestId("remote-detail-card");
|
||||
const renameButton = within(detailCard).getByTitle("Edit remote name");
|
||||
await user.click(renameButton);
|
||||
|
||||
const nameInput = screen.getByDisplayValue("origin");
|
||||
|
||||
@@ -18720,100 +18720,169 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Remote Management (enhanced) ── */
|
||||
/* ── Remotes Two-Column Layout ── */
|
||||
|
||||
.gm-remotes-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gm-remote-form {
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.gm-form-row {
|
||||
.gm-remotes-layout {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gm-form-row .gm-input {
|
||||
gap: var(--space-md);
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gm-form-row .gm-input-url {
|
||||
flex: 2;
|
||||
/* ── Remote Selector (Left Column) ── */
|
||||
|
||||
.gm-remote-selector {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.gm-remote-operations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.gm-remote-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
max-height: 400px;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gm-remote-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.gm-remote-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.gm-remote-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
gap: var(--space-xs);
|
||||
padding-right: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-name-row {
|
||||
.gm-remote-selector-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-xs) 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gm-remote-name {
|
||||
.gm-remote-selector-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.gm-remote-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.gm-remote-selector-item:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.gm-remote-selector-item.selected {
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary-bg, color-mix(in srgb, var(--primary) 8%, transparent));
|
||||
}
|
||||
|
||||
.gm-remote-selector-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-selector-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gm-remote-urls {
|
||||
.gm-remote-default-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.gm-remote-selector-host {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Remote Detail Panel (Right Column) ── */
|
||||
|
||||
.gm-remote-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sync Card ── */
|
||||
|
||||
.gm-remote-sync-card {
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-sync-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-sync-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Detail Card ── */
|
||||
|
||||
.gm-remote-detail-card {
|
||||
padding: var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-detail-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gm-remote-url {
|
||||
.gm-remote-detail-url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gm-url-label {
|
||||
@@ -18830,77 +18899,61 @@ html .column.drag-over * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-url.gm-push-url {
|
||||
.gm-remote-detail-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-url.gm-push-url .gm-url-value {
|
||||
flex: 1;
|
||||
padding-top: var(--space-xs);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gm-remote-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-edit.gm-url-edit {
|
||||
margin-left: 44px;
|
||||
}
|
||||
|
||||
.gm-remote-edit .gm-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gm-remote-actions-inline {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* ── Remote Section (reused for commits) ── */
|
||||
|
||||
.gm-remote-actions-inline .btn {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-remote-item.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-bg, color-mix(in srgb, var(--primary) 8%, transparent));
|
||||
}
|
||||
|
||||
.gm-commits-to-push {
|
||||
padding: var(--space-sm) 0;
|
||||
.gm-remote-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-commits-to-push h5 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
/* ── Remote Form ── */
|
||||
|
||||
.gm-commits-count {
|
||||
.gm-remote-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-commits-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-sm) 0;
|
||||
.gm-remote-form .gm-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gm-remote-form .gm-input-url {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.gm-remote-form .btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* ── Commit Items (shared) ── */
|
||||
|
||||
.gm-commit-item-compact {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -18945,34 +18998,6 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-remote-commits-section {
|
||||
margin-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
|
||||
.gm-remote-commits-section .gm-section-header {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-commits-section h5 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.gm-remote-commits-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Remotes Tab: Clickable Commit Diff ── */
|
||||
|
||||
.gm-commit-item-compact-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -19010,7 +19035,8 @@ html .column.drag-over * {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.gm-ahead-commits-list {
|
||||
.gm-ahead-commits-list,
|
||||
.gm-remote-commits-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
@@ -19092,34 +19118,55 @@ html .column.drag-over * {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gm-remote-form .gm-form-row {
|
||||
.gm-remotes-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gm-remote-selector {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-right: 0;
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-selector-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: 0 var(--space-xs) 0 0;
|
||||
}
|
||||
|
||||
.gm-remote-selector-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gm-remote-detail {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.gm-remote-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gm-remote-form .gm-form-row .gm-input,
|
||||
.gm-remote-form .gm-form-row .gm-input-url {
|
||||
flex: 1 1 100%;
|
||||
.gm-remote-form .gm-input,
|
||||
.gm-remote-form .gm-input-url {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.gm-remote-item {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-actions-inline {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.gm-remote-edit {
|
||||
.gm-remote-detail-url-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gm-remote-edit.gm-url-edit {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.gm-commit-form .gm-commit-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -19171,6 +19218,44 @@ html .column.drag-over * {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* ── Remotes Two-Column Layout Light Theme ── */
|
||||
|
||||
[data-theme="light"] .gm-remote-selector {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-right-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-selector-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-selector-item.selected {
|
||||
background: rgba(9, 105, 218, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-selector-item.selected:hover {
|
||||
background: rgba(9, 105, 218, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-sync-card {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-detail-card {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-form {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-remote-detail-name-row {
|
||||
border-top-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-hash {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user