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:
gsxdsm
2026-04-16 02:49:48 -07:00
parent 45b6b5cfad
commit e4aab15cac
3 changed files with 737 additions and 610 deletions

View File

@@ -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>
);
}

View File

@@ -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");

View File

@@ -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);
}