mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Split workspace cleanup presentation modules
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import type {
|
||||
WorkspaceCleanupContextFilter,
|
||||
WorkspaceCleanupFilters,
|
||||
WorkspaceCleanupGitFilter,
|
||||
WorkspaceCleanupReviewFilter,
|
||||
WorkspaceCleanupReviewInfo,
|
||||
WorkspaceCleanupSortDirection,
|
||||
WorkspaceCleanupSortKey,
|
||||
WorkspaceCleanupTimeFilter
|
||||
} from './workspace-cleanup-presentation'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: false,
|
||||
label: null,
|
||||
state: null,
|
||||
provider: null,
|
||||
title: null
|
||||
}
|
||||
|
||||
const EMPTY_REVIEW_INFO_MAP = new Map<string, WorkspaceCleanupReviewInfo>()
|
||||
|
||||
export function hasWorkspaceCleanupLocalContext(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return (
|
||||
candidate.localContext.terminalTabCount > 0 ||
|
||||
candidate.localContext.cleanEditorTabCount > 0 ||
|
||||
candidate.localContext.browserTabCount > 0 ||
|
||||
candidate.localContext.diffCommentCount > 0 ||
|
||||
candidate.localContext.retainedDoneAgentCount > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupSearchText(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
reviewInfo: WorkspaceCleanupReviewInfo = EMPTY_REVIEW_INFO
|
||||
): string {
|
||||
return [
|
||||
candidate.displayName,
|
||||
candidate.repoName,
|
||||
candidate.branch,
|
||||
candidate.path,
|
||||
reviewInfo.label,
|
||||
reviewInfo.title,
|
||||
getWorkspaceCleanupGitLabel(candidate),
|
||||
hasWorkspaceCleanupLocalContext(candidate) ? 'has context' : 'no context'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function filterWorkspaceCleanupCandidates(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
filters: WorkspaceCleanupFilters,
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo> = EMPTY_REVIEW_INFO_MAP,
|
||||
now: number = Date.now()
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const normalizedQuery = filters.query.trim().toLowerCase()
|
||||
return candidates.filter((candidate) => {
|
||||
const reviewInfo = reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
|
||||
if (
|
||||
normalizedQuery &&
|
||||
!getWorkspaceCleanupSearchText(candidate, reviewInfo).includes(normalizedQuery)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
matchesTimeFilter(candidate, filters.time, now) &&
|
||||
matchesReviewFilter(reviewInfo, filters.review) &&
|
||||
matchesGitFilter(candidate, filters.git) &&
|
||||
matchesContextFilter(candidate, filters.context)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function sortWorkspaceCleanupCandidates(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
sortKey: WorkspaceCleanupSortKey,
|
||||
direction: WorkspaceCleanupSortDirection,
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo> = EMPTY_REVIEW_INFO_MAP
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const multiplier = direction === 'asc' ? 1 : -1
|
||||
return [...candidates].sort((left, right) => {
|
||||
const primary =
|
||||
compareWorkspaceCleanupCandidates(left, right, sortKey, reviewInfoByWorktreeId) * multiplier
|
||||
return (
|
||||
primary ||
|
||||
left.lastActivityAt - right.lastActivityAt ||
|
||||
left.repoName.localeCompare(right.repoName) ||
|
||||
left.displayName.localeCompare(right.displayName)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string {
|
||||
if (hasUnpushedCommits(candidate)) {
|
||||
return 'Unpushed'
|
||||
}
|
||||
if (isGitStatusUnknown(candidate)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
if (candidate.git.clean === true) {
|
||||
return 'Clean'
|
||||
}
|
||||
if (candidate.git.clean === false) {
|
||||
return 'Dirty'
|
||||
}
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
function compareWorkspaceCleanupCandidates(
|
||||
left: WorkspaceCleanupCandidate,
|
||||
right: WorkspaceCleanupCandidate,
|
||||
sortKey: WorkspaceCleanupSortKey,
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
|
||||
): number {
|
||||
switch (sortKey) {
|
||||
case 'activity':
|
||||
return left.lastActivityAt - right.lastActivityAt
|
||||
case 'name':
|
||||
return left.displayName.localeCompare(right.displayName)
|
||||
case 'repo':
|
||||
return (
|
||||
left.repoName.localeCompare(right.repoName) ||
|
||||
left.displayName.localeCompare(right.displayName)
|
||||
)
|
||||
case 'review':
|
||||
return (
|
||||
getReviewSortRank(reviewInfoByWorktreeId.get(left.worktreeId) ?? EMPTY_REVIEW_INFO) -
|
||||
getReviewSortRank(reviewInfoByWorktreeId.get(right.worktreeId) ?? EMPTY_REVIEW_INFO) ||
|
||||
(reviewInfoByWorktreeId.get(left.worktreeId)?.label ?? '').localeCompare(
|
||||
reviewInfoByWorktreeId.get(right.worktreeId)?.label ?? ''
|
||||
)
|
||||
)
|
||||
case 'git':
|
||||
return getGitSortRank(left) - getGitSortRank(right)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesTimeFilter(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
filter: WorkspaceCleanupTimeFilter,
|
||||
now: number
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case '30d':
|
||||
return now - candidate.lastActivityAt >= 30 * DAY_MS
|
||||
case '90d':
|
||||
return now - candidate.lastActivityAt >= 90 * DAY_MS
|
||||
case 'archived':
|
||||
return candidate.reasons.includes('archived')
|
||||
}
|
||||
}
|
||||
|
||||
function matchesReviewFilter(
|
||||
reviewInfo: WorkspaceCleanupReviewInfo,
|
||||
filter: WorkspaceCleanupReviewFilter
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'no-review':
|
||||
return !reviewInfo.hasReview
|
||||
case 'has-review':
|
||||
return reviewInfo.hasReview
|
||||
case 'open-review':
|
||||
return reviewInfo.hasReview && (reviewInfo.state === 'open' || reviewInfo.state === 'draft')
|
||||
case 'closed-review':
|
||||
return (
|
||||
reviewInfo.hasReview && (reviewInfo.state === 'closed' || reviewInfo.state === 'merged')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesGitFilter(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
filter: WorkspaceCleanupGitFilter
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'clean':
|
||||
return (
|
||||
candidate.git.clean === true &&
|
||||
!hasUnpushedCommits(candidate) &&
|
||||
!isGitStatusUnknown(candidate)
|
||||
)
|
||||
case 'dirty':
|
||||
return candidate.git.clean === false
|
||||
case 'unpushed':
|
||||
return hasUnpushedCommits(candidate)
|
||||
case 'unknown':
|
||||
return isGitStatusUnknown(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesContextFilter(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
filter: WorkspaceCleanupContextFilter
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'has-context':
|
||||
return hasWorkspaceCleanupLocalContext(candidate)
|
||||
case 'no-context':
|
||||
return !hasWorkspaceCleanupLocalContext(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewSortRank(reviewInfo: WorkspaceCleanupReviewInfo): number {
|
||||
if (!reviewInfo.hasReview) {
|
||||
return 0
|
||||
}
|
||||
if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') {
|
||||
return 3
|
||||
}
|
||||
if (reviewInfo.state === 'unknown') {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function getGitSortRank(candidate: WorkspaceCleanupCandidate): number {
|
||||
if (hasUnpushedCommits(candidate)) {
|
||||
return 4
|
||||
}
|
||||
if (candidate.git.clean === false) {
|
||||
return 3
|
||||
}
|
||||
if (isGitStatusUnknown(candidate)) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function hasUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.blockers.includes('unpushed-commits')
|
||||
}
|
||||
|
||||
function isGitStatusUnknown(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return (
|
||||
candidate.git.clean === null ||
|
||||
candidate.blockers.includes('git-status-error') ||
|
||||
candidate.blockers.includes('unknown-base')
|
||||
)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation'
|
||||
|
||||
export const NOW = 1_700_000_000_000
|
||||
|
||||
const REPO: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: NOW
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: WorkspaceCleanupFilters = {
|
||||
query: '',
|
||||
time: 'all',
|
||||
review: 'all',
|
||||
git: 'all',
|
||||
context: 'all'
|
||||
}
|
||||
|
||||
export function makeCandidate(
|
||||
overrides: Partial<WorkspaceCleanupCandidate> = {}
|
||||
): WorkspaceCleanupCandidate {
|
||||
return {
|
||||
worktreeId: 'repo-1::/repo/alpha',
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
connectionId: null,
|
||||
displayName: 'alpha',
|
||||
branch: 'alpha',
|
||||
path: '/repo/alpha',
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: 0,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: NOW
|
||||
},
|
||||
fingerprint: 'fingerprint',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/repo/alpha',
|
||||
repoId: 'repo-1',
|
||||
displayName: 'alpha',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: NOW,
|
||||
path: '/repo/alpha',
|
||||
head: 'abc',
|
||||
branch: 'refs/heads/alpha',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
|
||||
return {
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'Review alpha cleanup',
|
||||
state: 'open',
|
||||
url: 'https://example.test/review/42',
|
||||
status: 'neutral',
|
||||
updatedAt: new Date(NOW).toISOString(),
|
||||
mergeable: 'UNKNOWN',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
return {
|
||||
worktreesByRepo: {
|
||||
'repo-1': [makeWorktree()]
|
||||
},
|
||||
repos: [REPO],
|
||||
hostedReviewCache: {},
|
||||
settings: {},
|
||||
...overrides
|
||||
} as AppState & Partial<AppState>
|
||||
}
|
||||
+7
-108
@@ -1,11 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: cleanup presentation tests share candidate,
|
||||
review-cache, filter, and sort fixtures that should stay in one behavioral suite. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
filterWorkspaceCleanupCandidates,
|
||||
getWorkspaceCleanupGitLabel,
|
||||
@@ -13,110 +7,15 @@ import {
|
||||
getWorkspaceCleanupSearchText,
|
||||
hasWorkspaceCleanupLocalContext,
|
||||
sortWorkspaceCleanupCandidates,
|
||||
type WorkspaceCleanupFilters,
|
||||
type WorkspaceCleanupReviewInfo
|
||||
} from './workspace-cleanup-presentation'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const REPO: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: NOW
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: WorkspaceCleanupFilters = {
|
||||
query: '',
|
||||
time: 'all',
|
||||
review: 'all',
|
||||
git: 'all',
|
||||
context: 'all'
|
||||
}
|
||||
|
||||
function makeCandidate(
|
||||
overrides: Partial<WorkspaceCleanupCandidate> = {}
|
||||
): WorkspaceCleanupCandidate {
|
||||
return {
|
||||
worktreeId: 'repo-1::/repo/alpha',
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
connectionId: null,
|
||||
displayName: 'alpha',
|
||||
branch: 'alpha',
|
||||
path: '/repo/alpha',
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: {
|
||||
clean: true,
|
||||
upstreamAhead: 0,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: NOW
|
||||
},
|
||||
fingerprint: 'fingerprint',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/repo/alpha',
|
||||
repoId: 'repo-1',
|
||||
displayName: 'alpha',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: NOW,
|
||||
path: '/repo/alpha',
|
||||
head: 'abc',
|
||||
branch: 'refs/heads/alpha',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
|
||||
return {
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'Review alpha cleanup',
|
||||
state: 'open',
|
||||
url: 'https://example.test/review/42',
|
||||
status: 'neutral',
|
||||
updatedAt: new Date(NOW).toISOString(),
|
||||
mergeable: 'UNKNOWN',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
return {
|
||||
worktreesByRepo: {
|
||||
'repo-1': [makeWorktree()]
|
||||
},
|
||||
repos: [REPO],
|
||||
hostedReviewCache: {},
|
||||
settings: {},
|
||||
...overrides
|
||||
} as AppState & Partial<AppState>
|
||||
}
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
NOW,
|
||||
makeCandidate,
|
||||
makeReview,
|
||||
makeState
|
||||
} from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
describe('workspace cleanup presentation', () => {
|
||||
it('finds hosted review details from renderer state', () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: cleanup filtering, review enrichment,
|
||||
sorting, and label logic share one row model and test fixture surface. */
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -40,17 +38,13 @@ export type WorkspaceCleanupRendererStateInputs = Pick<
|
||||
'worktreesByRepo' | 'hostedReviewCache' | 'repos' | 'settings'
|
||||
>
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function hasWorkspaceCleanupLocalContext(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return (
|
||||
candidate.localContext.terminalTabCount > 0 ||
|
||||
candidate.localContext.cleanEditorTabCount > 0 ||
|
||||
candidate.localContext.browserTabCount > 0 ||
|
||||
candidate.localContext.diffCommentCount > 0 ||
|
||||
candidate.localContext.retainedDoneAgentCount > 0
|
||||
)
|
||||
}
|
||||
export {
|
||||
filterWorkspaceCleanupCandidates,
|
||||
getWorkspaceCleanupGitLabel,
|
||||
getWorkspaceCleanupSearchText,
|
||||
hasWorkspaceCleanupLocalContext,
|
||||
sortWorkspaceCleanupCandidates
|
||||
} from './workspace-cleanup-filter-sort'
|
||||
|
||||
export function getWorkspaceCleanupReviewInfo(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
@@ -131,238 +125,3 @@ function getReviewShortLabel(provider: HostedReviewProvider): string {
|
||||
function getBranchDisplayName(branch: string): string {
|
||||
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupSearchText(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
reviewInfo: WorkspaceCleanupReviewInfo = EMPTY_REVIEW_INFO
|
||||
): string {
|
||||
return [
|
||||
candidate.displayName,
|
||||
candidate.repoName,
|
||||
candidate.branch,
|
||||
candidate.path,
|
||||
reviewInfo.label,
|
||||
reviewInfo.title,
|
||||
getWorkspaceCleanupGitLabel(candidate),
|
||||
hasWorkspaceCleanupLocalContext(candidate) ? 'has context' : 'no context'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function filterWorkspaceCleanupCandidates(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
filters: WorkspaceCleanupFilters,
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo> = EMPTY_REVIEW_INFO_MAP,
|
||||
now: number = Date.now()
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const normalizedQuery = filters.query.trim().toLowerCase()
|
||||
return candidates.filter((candidate) => {
|
||||
const reviewInfo = reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
|
||||
if (
|
||||
normalizedQuery &&
|
||||
!getWorkspaceCleanupSearchText(candidate, reviewInfo).includes(normalizedQuery)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!matchesTimeFilter(candidate, filters.time, now)) {
|
||||
return false
|
||||
}
|
||||
if (!matchesReviewFilter(reviewInfo, filters.review)) {
|
||||
return false
|
||||
}
|
||||
if (!matchesGitFilter(candidate, filters.git)) {
|
||||
return false
|
||||
}
|
||||
if (!matchesContextFilter(candidate, filters.context)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function sortWorkspaceCleanupCandidates(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
sortKey: WorkspaceCleanupSortKey,
|
||||
direction: WorkspaceCleanupSortDirection,
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo> = EMPTY_REVIEW_INFO_MAP
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const multiplier = direction === 'asc' ? 1 : -1
|
||||
return [...candidates].sort((left, right) => {
|
||||
const primary =
|
||||
compareWorkspaceCleanupCandidates(left, right, sortKey, reviewInfoByWorktreeId) * multiplier
|
||||
return (
|
||||
primary ||
|
||||
left.lastActivityAt - right.lastActivityAt ||
|
||||
left.repoName.localeCompare(right.repoName) ||
|
||||
left.displayName.localeCompare(right.displayName)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function compareWorkspaceCleanupCandidates(
|
||||
left: WorkspaceCleanupCandidate,
|
||||
right: WorkspaceCleanupCandidate,
|
||||
sortKey: WorkspaceCleanupSortKey,
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
|
||||
): number {
|
||||
switch (sortKey) {
|
||||
case 'activity':
|
||||
return left.lastActivityAt - right.lastActivityAt
|
||||
case 'name':
|
||||
return left.displayName.localeCompare(right.displayName)
|
||||
case 'repo':
|
||||
return (
|
||||
left.repoName.localeCompare(right.repoName) ||
|
||||
left.displayName.localeCompare(right.displayName)
|
||||
)
|
||||
case 'review':
|
||||
return (
|
||||
getReviewSortRank(reviewInfoByWorktreeId.get(left.worktreeId) ?? EMPTY_REVIEW_INFO) -
|
||||
getReviewSortRank(reviewInfoByWorktreeId.get(right.worktreeId) ?? EMPTY_REVIEW_INFO) ||
|
||||
(reviewInfoByWorktreeId.get(left.worktreeId)?.label ?? '').localeCompare(
|
||||
reviewInfoByWorktreeId.get(right.worktreeId)?.label ?? ''
|
||||
)
|
||||
)
|
||||
case 'git':
|
||||
return getGitSortRank(left) - getGitSortRank(right)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesTimeFilter(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
filter: WorkspaceCleanupTimeFilter,
|
||||
now: number
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case '30d':
|
||||
return now - candidate.lastActivityAt >= 30 * DAY_MS
|
||||
case '90d':
|
||||
return now - candidate.lastActivityAt >= 90 * DAY_MS
|
||||
case 'archived':
|
||||
return candidate.reasons.includes('archived')
|
||||
}
|
||||
}
|
||||
|
||||
function matchesReviewFilter(
|
||||
reviewInfo: WorkspaceCleanupReviewInfo,
|
||||
filter: WorkspaceCleanupReviewFilter
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'no-review':
|
||||
return !reviewInfo.hasReview
|
||||
case 'has-review':
|
||||
return reviewInfo.hasReview
|
||||
case 'open-review':
|
||||
return reviewInfo.hasReview && (reviewInfo.state === 'open' || reviewInfo.state === 'draft')
|
||||
case 'closed-review':
|
||||
return (
|
||||
reviewInfo.hasReview && (reviewInfo.state === 'closed' || reviewInfo.state === 'merged')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesGitFilter(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
filter: WorkspaceCleanupGitFilter
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'clean':
|
||||
return (
|
||||
candidate.git.clean === true &&
|
||||
!hasUnpushedCommits(candidate) &&
|
||||
!isGitStatusUnknown(candidate)
|
||||
)
|
||||
case 'dirty':
|
||||
return candidate.git.clean === false
|
||||
case 'unpushed':
|
||||
return hasUnpushedCommits(candidate)
|
||||
case 'unknown':
|
||||
return isGitStatusUnknown(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
function matchesContextFilter(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
filter: WorkspaceCleanupContextFilter
|
||||
): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'has-context':
|
||||
return hasWorkspaceCleanupLocalContext(candidate)
|
||||
case 'no-context':
|
||||
return !hasWorkspaceCleanupLocalContext(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
function getReviewSortRank(reviewInfo: WorkspaceCleanupReviewInfo): number {
|
||||
if (!reviewInfo.hasReview) {
|
||||
return 0
|
||||
}
|
||||
if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') {
|
||||
return 3
|
||||
}
|
||||
if (reviewInfo.state === 'unknown') {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function getGitSortRank(candidate: WorkspaceCleanupCandidate): number {
|
||||
if (hasUnpushedCommits(candidate)) {
|
||||
return 4
|
||||
}
|
||||
if (candidate.git.clean === false) {
|
||||
return 3
|
||||
}
|
||||
if (isGitStatusUnknown(candidate)) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string {
|
||||
if (hasUnpushedCommits(candidate)) {
|
||||
return 'Unpushed'
|
||||
}
|
||||
if (isGitStatusUnknown(candidate)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
if (candidate.git.clean === true) {
|
||||
return 'Clean'
|
||||
}
|
||||
if (candidate.git.clean === false) {
|
||||
return 'Dirty'
|
||||
}
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
function hasUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.blockers.includes('unpushed-commits')
|
||||
}
|
||||
|
||||
function isGitStatusUnknown(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return (
|
||||
candidate.git.clean === null ||
|
||||
candidate.blockers.includes('git-status-error') ||
|
||||
candidate.blockers.includes('unknown-base')
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: false,
|
||||
label: null,
|
||||
state: null,
|
||||
provider: null,
|
||||
title: null
|
||||
}
|
||||
|
||||
const EMPTY_REVIEW_INFO_MAP = new Map<string, WorkspaceCleanupReviewInfo>()
|
||||
|
||||
Reference in New Issue
Block a user