diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts new file mode 100644 index 00000000000..ff597290bfd --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts @@ -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() + +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 = 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 = 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 +): 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') + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts new file mode 100644 index 00000000000..a9f6b5362da --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -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 { + 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 { + 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 { + 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 { + return { + worktreesByRepo: { + 'repo-1': [makeWorktree()] + }, + repos: [REPO], + hostedReviewCache: {}, + settings: {}, + ...overrides + } as AppState & Partial +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts index 0b714be3790..f6e1aab4488 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts @@ -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 { - 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 { - 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 { - 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 { - return { - worktreesByRepo: { - 'repo-1': [makeWorktree()] - }, - repos: [REPO], - hostedReviewCache: {}, - settings: {}, - ...overrides - } as AppState & Partial -} +import { + DEFAULT_FILTERS, + NOW, + makeCandidate, + makeReview, + makeState +} from './workspace-cleanup-presentation-fixtures' describe('workspace cleanup presentation', () => { it('finds hosted review details from renderer state', () => { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index 142ab18f52e..ad860e1f7e2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -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 = 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 = 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 -): 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()