diff --git a/addons/isl-server/src/Repository.ts b/addons/isl-server/src/Repository.ts index f2f1494ae364e..a3c6bd7cd7508 100644 --- a/addons/isl-server/src/Repository.ts +++ b/addons/isl-server/src/Repository.ts @@ -31,11 +31,11 @@ import type { SettableConfigName, ShelvedChange, StableInfo, - WorktreeInfo, Submodule, SubmodulesByRoot, UncommittedChanges, ValidatedRepoInfo, + WorktreeInfo, } from 'isl/src/types'; import type {Comparison} from 'shared/Comparison'; import type {EjecaChildProcess, EjecaOptions} from 'shared/ejeca'; @@ -1562,13 +1562,14 @@ export class Repository { } public async getWorktrees(ctx: RepositoryContext): Promise> { - const result = await this.runCommand( - ['wt', 'list', '--json'], - 'WorktreeListCommand', - ctx, - ); + const result = await this.runCommand(['wt', 'list', '--json'], 'WorktreeListCommand', ctx); try { - return JSON.parse(result.stdout) as Array; + const worktrees = JSON.parse(result.stdout) as Array; + // Filter out stale worktrees whose directories no longer exist on disk + const validated = await Promise.all( + worktrees.map(async wt => ({wt, pathExists: await exists(wt.path)})), + ); + return validated.filter(({pathExists}) => pathExists).map(({wt}) => wt); } catch (err) { ctx.logger.error('Failed to parse worktree list output:', err); return []; diff --git a/addons/isl/src/Avatar.tsx b/addons/isl/src/Avatar.tsx index 5459c68b00552..699ac32911910 100644 --- a/addons/isl/src/Avatar.tsx +++ b/addons/isl/src/Avatar.tsx @@ -8,11 +8,13 @@ import type {DetailedHTMLProps} from 'react'; import * as stylex from '@stylexjs/stylex'; -import {useAtomValue} from 'jotai'; +import {atom, useAtomValue} from 'jotai'; import {colors, radius} from '../../components/theme/tokens.stylex'; import serverAPI from './ClientToServerAPI'; +import {allDiffSummaries} from './codeReview/CodeReviewInfo'; import {t} from './i18n'; import {atomFamilyWeak, lazyAtom} from './jotaiUtils'; +import {dagWithPreviews} from './previews'; export const avatarUrl = atomFamilyWeak((author: string) => { // Rate limitor for the same author is by lazyAtom and atomFamilyWeak caching. @@ -28,6 +30,34 @@ export const avatarUrl = atomFamilyWeak((author: string) => { }, undefined); }); +/** + * Shared cache mapping git author strings to GitHub avatar URLs. + * Built by cross-referencing diff summaries (PR data with avatar URLs) + * with the commit DAG (which maps hashes to git author strings). + */ +export const gitAuthorAvatarCache = atom(get => { + const summariesResult = get(allDiffSummaries); + const dag = get(dagWithPreviews); + const cache = new Map(); + + if (!summariesResult?.value) { + return cache; + } + + for (const summary of summariesResult.value.values()) { + if (summary.type !== 'github' || !summary.authorAvatarUrl) { + continue; + } + // Match the PR's head commit hash to a DAG commit to get the git author string + const commit = dag.get(summary.head); + if (commit?.author) { + cache.set(commit.author, summary.authorAvatarUrl); + } + } + + return cache; +}); + export function AvatarImg({ url, username, @@ -162,8 +192,24 @@ export function InitialsAvatar({username, size = 20}: {username: string; size?: ); } +export function SkeletonAvatar({size = 20}: {size?: number}) { + return ( +
+ ); +} + export function CommitAvatar({username, size = 20}: {username: string; size?: number}) { - const url = useAtomValue(avatarUrl(username)); + const fetchedUrl = useAtomValue(avatarUrl(username)); + const cachedAvatars = useAtomValue(gitAuthorAvatarCache); + const url = fetchedUrl ?? cachedAvatars.get(username); if (url) { return ( @@ -177,5 +223,5 @@ export function CommitAvatar({username, size = 20}: {username: string; size?: nu ); } - return ; + return ; } diff --git a/addons/isl/src/Commit.tsx b/addons/isl/src/Commit.tsx index 81240ad9fc693..4b7a0366f5773 100644 --- a/addons/isl/src/Commit.tsx +++ b/addons/isl/src/Commit.tsx @@ -26,8 +26,8 @@ import {MS_PER_DAY} from 'shared/constants'; import {useAutofocusRef} from 'shared/hooks'; import {notEmpty, nullthrows} from 'shared/utils'; import {spacing} from '../../components/theme/tokens.stylex'; -import {AllBookmarksTruncated, Bookmark, Bookmarks, createBookmarkAtCommit} from './Bookmark'; import {CommitAvatar} from './Avatar'; +import {AllBookmarksTruncated, Bookmark, Bookmarks, createBookmarkAtCommit} from './Bookmark'; import {openBrowseUrlForHash, supportsBrowseUrlForHash} from './BrowseRepo'; import {hasUnsavedEditedCommitMessage} from './CommitInfoView/CommitInfoState'; import {showComparison} from './ComparisonView/atoms'; @@ -41,6 +41,7 @@ import {SubmitSingleCommitButton} from './SubmitSingleCommitButton'; import {getSuggestedRebaseOperation, suggestedRebaseDestinations} from './SuggestedRebase'; import {UncommitButton} from './UncommitButton'; import {UncommittedChanges} from './UncommittedChanges'; +import {WorktreeIndicator} from './WorktreeIndicator'; import {tracker} from './analytics'; import {clipboardLinkHtml} from './clipboard'; import { @@ -50,13 +51,13 @@ import { diffSummary, latestCommitMessageTitle, } from './codeReview/CodeReviewInfo'; +import {DiffBadge, DiffFollower, DiffInfo} from './codeReview/DiffBadge'; import { - showOnlyMyStacksAtom, hideBotStacksAtom, hideMergedStacksAtom, isBotAuthor, + showOnlyMyStacksAtom, } from './codeReview/PRStacksAtom'; -import {DiffBadge, DiffFollower, DiffInfo} from './codeReview/DiffBadge'; import {SyncStatus, syncStatusAtom} from './codeReview/syncStatus'; import {useFeatureFlagSync} from './featureFlags'; import {FoldButton, useRunFoldPreview} from './fold'; @@ -97,7 +98,6 @@ import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils'; import {copyAndShowToast} from './toast'; import {showModal} from './useModal'; import {short} from './utils'; -import {WorktreeIndicator} from './WorktreeIndicator'; export const rebaseOffWarmWarningEnabled = localStorageBackedAtom( 'isl.rebase-off-warm-warning-enabled', diff --git a/addons/isl/src/CommitTreeList.css b/addons/isl/src/CommitTreeList.css index 5583f92e7535e..9cf1a5a40a266 100644 --- a/addons/isl/src/CommitTreeList.css +++ b/addons/isl/src/CommitTreeList.css @@ -117,7 +117,7 @@ /* Gradient highlight from left to transparent - visible, extends across most of the row */ background: linear-gradient( 90deg, - rgba(74, 144, 226, 0.20) 0%, + rgba(74, 144, 226, 0.2) 0%, rgba(74, 144, 226, 0.14) 20%, rgba(74, 144, 226, 0.08) 45%, rgba(74, 144, 226, 0.03) 70%, @@ -350,11 +350,7 @@ .commit.origin-main-commit .commit-details { /* Very subtle background tint */ - background: linear-gradient( - 90deg, - rgba(74, 144, 226, 0.08) 0%, - transparent 50% - ); + background: linear-gradient(90deg, rgba(74, 144, 226, 0.08) 0%, transparent 50%); } .origin-main-badge { @@ -392,18 +388,52 @@ flex-shrink: 0; } +/* Skeleton avatar placeholder (loading state) */ +.avatar-skeleton { + background: linear-gradient( + 90deg, + var(--subtle-hover-darken) 0%, + color-mix(in srgb, var(--foreground) 12%, var(--subtle-hover-darken)) 50%, + var(--subtle-hover-darken) 100% + ); + background-size: 200% 100%; + animation: avatar-skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes avatar-skeleton-pulse { + 0% { + opacity: 0.3; + } + 50% { + opacity: 0.6; + } + 100% { + opacity: 0.3; + } +} + /* ===== COMMIT TREE LOADING ===== */ /* Pulsing animation */ @keyframes skeleton-pulse { - 0% { opacity: 0.4; } - 50% { opacity: 0.7; } - 100% { opacity: 0.4; } + 0% { + opacity: 0.4; + } + 50% { + opacity: 0.7; + } + 100% { + opacity: 0.4; + } } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } /* Loading container */ @@ -459,17 +489,37 @@ } /* Stagger animation delays */ -.top-bar-skeleton-left .skeleton-box:nth-child(1) { animation-delay: 0s; } -.top-bar-skeleton-left .skeleton-box:nth-child(2) { animation-delay: 0.1s; } -.top-bar-skeleton-left .skeleton-box:nth-child(3) { animation-delay: 0.2s; } -.top-bar-skeleton-left .skeleton-box:nth-child(4) { animation-delay: 0.3s; } -.top-bar-skeleton-left .skeleton-box:nth-child(5) { animation-delay: 0.4s; } - -.top-bar-skeleton-right .skeleton-box:nth-child(1) { animation-delay: 0.15s; } -.top-bar-skeleton-right .skeleton-box:nth-child(2) { animation-delay: 0.25s; } -.top-bar-skeleton-right .skeleton-box:nth-child(3) { animation-delay: 0.35s; } -.top-bar-skeleton-right .skeleton-box:nth-child(4) { animation-delay: 0.45s; } -.top-bar-skeleton-right .skeleton-box:nth-child(5) { animation-delay: 0.55s; } +.top-bar-skeleton-left .skeleton-box:nth-child(1) { + animation-delay: 0s; +} +.top-bar-skeleton-left .skeleton-box:nth-child(2) { + animation-delay: 0.1s; +} +.top-bar-skeleton-left .skeleton-box:nth-child(3) { + animation-delay: 0.2s; +} +.top-bar-skeleton-left .skeleton-box:nth-child(4) { + animation-delay: 0.3s; +} +.top-bar-skeleton-left .skeleton-box:nth-child(5) { + animation-delay: 0.4s; +} + +.top-bar-skeleton-right .skeleton-box:nth-child(1) { + animation-delay: 0.15s; +} +.top-bar-skeleton-right .skeleton-box:nth-child(2) { + animation-delay: 0.25s; +} +.top-bar-skeleton-right .skeleton-box:nth-child(3) { + animation-delay: 0.35s; +} +.top-bar-skeleton-right .skeleton-box:nth-child(4) { + animation-delay: 0.45s; +} +.top-bar-skeleton-right .skeleton-box:nth-child(5) { + animation-delay: 0.55s; +} /* Loading spinner area */ .commit-tree-loading-spinner { diff --git a/addons/isl/src/PRDashboard.css b/addons/isl/src/PRDashboard.css index fb20e5da60c55..e6ab663352e32 100644 --- a/addons/isl/src/PRDashboard.css +++ b/addons/isl/src/PRDashboard.css @@ -110,9 +110,9 @@ .pr-dashboard-content { padding: var(--pad); - /* Account for negative margins on parent */ - padding-left: calc(var(--pad) + 16px); - padding-right: calc(var(--pad) + 16px); + /* Account for negative margins on parent, keep padding tight for more card width */ + padding-left: calc(var(--halfpad) + 16px); + padding-right: calc(var(--halfpad) + 16px); } .pr-dashboard-empty { @@ -162,12 +162,12 @@ } .stack-card-title { - flex: 1; font-weight: 500; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; } .stack-card-pull-button { @@ -258,6 +258,18 @@ color: var(--signal-good); } +.pr-state-approved { + color: var(--signal-good); +} + +.pr-state-changes-requested { + color: #f59e0b; +} + +.pr-state-review-required { + color: var(--signal-warning); +} + .pr-state-merged { color: var(--scm-modified-foreground); } @@ -274,6 +286,73 @@ color: var(--signal-warning); } +/* ===== PR STATUS LEGEND TOOLTIP ===== */ + +.pr-status-legend { + min-width: 160px; + font-size: 12px; + line-height: 1; +} + +.pr-status-legend-current { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); +} + +.pr-status-legend-current-label { + font-weight: 600; + color: var(--foreground); + letter-spacing: 0.01em; +} + +.pr-status-legend-divider { + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 6px 0; +} + +.pr-status-legend-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px 12px; +} + +.pr-status-legend-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + border-radius: 3px; + opacity: 0.5; + transition: opacity 0.1s; +} + +.pr-status-legend-item:hover { + opacity: 0.8; +} + +.pr-status-legend-item-active { + opacity: 1; + background: rgba(255, 255, 255, 0.05); +} + +.pr-status-legend-icon { + font-size: 11px; + width: 14px; + text-align: center; + flex-shrink: 0; +} + +.pr-status-legend-label { + color: var(--foreground); + white-space: nowrap; + font-size: 11px; +} + /* Header buttons */ .pr-dashboard-header-buttons { display: flex; @@ -305,8 +384,7 @@ .stack-card-author { display: flex; align-items: center; - margin-left: auto; - margin-right: var(--halfpad); + margin-left: 4px; } .stack-card-avatar { @@ -320,6 +398,7 @@ display: flex; align-items: center; gap: 2px; + margin-left: auto; } /* Label editor */ @@ -605,26 +684,60 @@ } /* Stagger animation delays for visual interest */ -.stack-card-skeleton:nth-child(1) { animation-delay: 0s; } -.stack-card-skeleton:nth-child(2) { animation-delay: 0.1s; } -.stack-card-skeleton:nth-child(3) { animation-delay: 0.2s; } -.stack-card-skeleton:nth-child(4) { animation-delay: 0.3s; } -.stack-card-skeleton:nth-child(5) { animation-delay: 0.4s; } -.stack-card-skeleton:nth-child(6) { animation-delay: 0.5s; } -.stack-card-skeleton:nth-child(7) { animation-delay: 0.6s; } -.stack-card-skeleton:nth-child(8) { animation-delay: 0.7s; } +.stack-card-skeleton:nth-child(1) { + animation-delay: 0s; +} +.stack-card-skeleton:nth-child(2) { + animation-delay: 0.1s; +} +.stack-card-skeleton:nth-child(3) { + animation-delay: 0.2s; +} +.stack-card-skeleton:nth-child(4) { + animation-delay: 0.3s; +} +.stack-card-skeleton:nth-child(5) { + animation-delay: 0.4s; +} +.stack-card-skeleton:nth-child(6) { + animation-delay: 0.5s; +} +.stack-card-skeleton:nth-child(7) { + animation-delay: 0.6s; +} +.stack-card-skeleton:nth-child(8) { + animation-delay: 0.7s; +} /* Stagger skeleton boxes within each card */ -.stack-card-skeleton .skeleton-box:nth-child(1) { animation-delay: 0s; } -.stack-card-skeleton .skeleton-box:nth-child(2) { animation-delay: 0.05s; } -.stack-card-skeleton .skeleton-box:nth-child(3) { animation-delay: 0.1s; } -.stack-card-skeleton .skeleton-box:nth-child(4) { animation-delay: 0.15s; } - -.pr-row-skeleton:nth-child(1) .skeleton-box { animation-delay: 0.1s; } -.pr-row-skeleton:nth-child(2) .skeleton-box { animation-delay: 0.15s; } -.pr-row-skeleton:nth-child(3) .skeleton-box { animation-delay: 0.2s; } -.pr-row-skeleton:nth-child(4) .skeleton-box { animation-delay: 0.25s; } -.pr-row-skeleton:nth-child(5) .skeleton-box { animation-delay: 0.3s; } +.stack-card-skeleton .skeleton-box:nth-child(1) { + animation-delay: 0s; +} +.stack-card-skeleton .skeleton-box:nth-child(2) { + animation-delay: 0.05s; +} +.stack-card-skeleton .skeleton-box:nth-child(3) { + animation-delay: 0.1s; +} +.stack-card-skeleton .skeleton-box:nth-child(4) { + animation-delay: 0.15s; +} + +.pr-row-skeleton:nth-child(1) .skeleton-box { + animation-delay: 0.1s; +} +.pr-row-skeleton:nth-child(2) .skeleton-box { + animation-delay: 0.15s; +} +.pr-row-skeleton:nth-child(3) .skeleton-box { + animation-delay: 0.2s; +} +.pr-row-skeleton:nth-child(4) .skeleton-box { + animation-delay: 0.25s; +} +.pr-row-skeleton:nth-child(5) .skeleton-box { + animation-delay: 0.3s; +} /* Time Range Dropdown */ .time-range-wrapper { diff --git a/addons/isl/src/PRDashboard.tsx b/addons/isl/src/PRDashboard.tsx index aef76c237f6de..c55571a4e028d 100644 --- a/addons/isl/src/PRDashboard.tsx +++ b/addons/isl/src/PRDashboard.tsx @@ -9,41 +9,43 @@ import type {PRStack} from './codeReview/PRStacksAtom'; import type {DiffSummary, TimeRangeDays} from './types'; import {Button} from 'isl-components/Button'; -import {Dropdown} from 'isl-components/Dropdown'; import {Icon} from 'isl-components/Icon'; import {TextField} from 'isl-components/TextField'; import {Tooltip} from 'isl-components/Tooltip'; import {useAtom, useAtomValue} from 'jotai'; -import {useState, useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {ComparisonType} from 'shared/Comparison'; import serverAPI from './ClientToServerAPI'; -import {showComparison} from './ComparisonView/atoms'; -import {enterReviewMode} from './reviewMode'; -import {currentGitHubUser, allDiffSummaries, triggerFullDiffSummariesRefresh} from './codeReview/CodeReviewInfo'; import { - prStacksAtom, - stackLabelsAtom, + allDiffSummaries, + currentGitHubUser, + triggerFullDiffSummariesRefresh, +} from './codeReview/CodeReviewInfo'; +import { hiddenStacksAtom, - hideMergedStacksAtom, - showOnlyMyStacksAtom, hideBotStacksAtom, + hideMergedStacksAtom, isBotAuthor, + prStacksAtom, + showOnlyMyStacksAtom, + stackLabelsAtom, } from './codeReview/PRStacksAtom'; -import {T} from './i18n'; import {scrollToCommit} from './CommitTreeList'; +import {showComparison} from './ComparisonView/atoms'; +import {T, t} from './i18n'; import {writeAtom} from './jotaiUtils'; -import {inlineProgressByHash, useRunOperation} from './operationsState'; -import {PullOperation} from './operations/PullOperation'; -import {GotoOperation} from './operations/GotoOperation'; import {ClosePROperation} from './operations/ClosePROperation'; +import {GotoOperation} from './operations/GotoOperation'; +import {PullOperation} from './operations/PullOperation'; import {WorktreeAddOperation} from './operations/WorktreeAddOperation'; -import {worktreesForCommit} from './worktrees'; -import {showToast} from './toast'; -import {t} from './i18n'; +import {inlineProgressByHash, useRunOperation} from './operationsState'; import {dagWithPreviews} from './previews'; +import {enterReviewMode} from './reviewMode'; import {selectedCommits} from './selection'; import {selectedTimeRangeAtom, setTimeRange} from './serverAPIState'; +import {showToast} from './toast'; import {succeedableRevset} from './types'; +import {worktreesForCommit} from './worktrees'; import './PRDashboard.css'; @@ -135,9 +137,14 @@ function MainBranchSection({}: {isScrolled?: boolean}) { // Find main/master bookmark in the dag const mainCommit = dag.resolve('main') ?? dag.resolve('master'); - const remoteName = mainCommit?.remoteBookmarks.find(b => - b === 'origin/main' || b === 'origin/master' || b === 'remote/main' || b === 'remote/master' - ) ?? 'main'; + const remoteName = + mainCommit?.remoteBookmarks.find( + b => + b === 'origin/main' || + b === 'origin/master' || + b === 'remote/main' || + b === 'remote/master', + ) ?? 'main'; // Check if we're currently on main const currentCommit = dag.resolve('.'); @@ -161,11 +168,7 @@ function MainBranchSection({}: {isScrolled?: boolean}) { runOperation(new GotoOperation(succeedableRevset(remoteName))); }, [isOnMain, isBehind, runOperation, remoteName]); - const syncStatusText = isBehind - ? 'Updates available' - : isOnMain - ? 'You are here' - : 'Up to date'; + const syncStatusText = isBehind ? 'Updates available' : isOnMain ? 'You are here' : 'Up to date'; const statusClass = isBehind ? 'main-branch-status main-branch-status-behind' @@ -182,13 +185,8 @@ function MainBranchSection({}: {isScrolled?: boolean}) { @@ -219,7 +217,9 @@ function TimeRangeDropdown() { value={selectedRange === undefined ? 'undefined' : String(selectedRange)} onChange={handleChange}> {TIME_RANGE_OPTIONS.map(opt => ( - ))} @@ -284,9 +284,7 @@ export function PRDashboard() { return true; }); - const hiddenCount = stacks.filter(stack => - hiddenStacks.includes(stack.id), - ).length; + const hiddenCount = stacks.filter(stack => hiddenStacks.includes(stack.id)).length; const mergedCount = stacks.filter(stack => stack.isMerged).length; @@ -302,24 +300,32 @@ export function PRDashboard() {
- PR Stacks (v4.2) + PR Stacks (v4.2.1)
{currentUser && ( + title={ + showOnlyMine + ? `Show all authors (${otherAuthorsCount} hidden)` + : 'Show only my stacks' + }> )} + title={ + hideBots ? `Show ${botCount} bot PRs` : 'Hide bot PRs (renovate, dependabot, etc)' + }> - + {hiddenCount > 0 && ( -