Split workspace cleanup presentation modules

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-26 13:56:26 -07:00
co-authored by Orca
parent bd39ad1f06
commit 2188047ee7
4 changed files with 372 additions and 356 deletions
@@ -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')
)
}
@@ -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>
}
@@ -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>()