refactor(settings): reuse existing helpers in the Workspace Cleanup page

One row key helper, useMountedRef, SettingsSwitchRow and the shared
runtime target builder; only the preview of rules about to be saved is
kept as the page's matches, and the report waits for the rules.
This commit is contained in:
Kelvin Amoaba
2026-11-13 19:42:32 +00:00
parent 6132c30596
commit 338de519d7
9 changed files with 92 additions and 77 deletions
@@ -29,7 +29,7 @@ export type WorkspaceCleanupApi = {
recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise<void>
finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise<void>
/** This app's own cleanup runner finished a run. */
onRunFinished?: (callback: (run: WorkspaceCleanupRunFinishedEvent['run']) => void) => () => void
onRunFinished: (callback: (run: WorkspaceCleanupRunFinishedEvent['run']) => void) => () => void
}
export type WorkspaceSpaceApi = {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
@@ -40,6 +40,8 @@ import {
} from './workspace-cleanup-rule-change-dialog'
import { WorkspaceCleanupActivityList } from './workspace-cleanup-activity-list'
import { getWorkspaceCleanupSettingsSearchParts } from './workspace-cleanup-settings-search'
import { workspaceCleanupRowKey } from './workspace-cleanup-workspace-line'
import { useMountedRef } from '@/hooks/useMountedRef'
function removalsFor(
preview: WorkspaceCleanupPreview,
@@ -51,20 +53,18 @@ function removalsFor(
)
}
function previewRowKey(row: WorkspaceCleanupPreviewRow): string {
return `${row.executionHostId ?? ''}:${row.worktreeId}`
}
/** Workspaces `next` would remove now that `current` would not. */
function newRemovals(
next: WorkspaceCleanupPreview,
current: WorkspaceCleanupPreview | null
): WorkspaceCleanupPreviewRow[] {
const removedNow = new Set(
(current?.rows ?? []).filter((row) => row.evaluation.action !== 'keep').map(previewRowKey)
(current?.rows ?? [])
.filter((row) => row.evaluation.action !== 'keep')
.map(workspaceCleanupRowKey)
)
return next.rows.filter(
(row) => row.evaluation.action !== 'keep' && !removedNow.has(previewRowKey(row))
(row) => row.evaluation.action !== 'keep' && !removedNow.has(workspaceCleanupRowKey(row))
)
}
@@ -114,13 +114,7 @@ export function WorkspaceCleanupSettingsPane({
const [busy, setBusy] = useState(false)
const busyRef = useRef(false)
// A change still previewing when the pane unmounts must not write over what came after it.
const mountedRef = useRef(false)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const mountedRef = useMountedRef()
// Remounts the rows after a change that was not saved, so an edited field shows the saved value.
const [unsavedChanges, setUnsavedChanges] = useState(0)
const search = getWorkspaceCleanupSettingsSearchParts()
@@ -198,18 +192,19 @@ export function WorkspaceCleanupSettingsPane({
let rows: WorkspaceCleanupPreviewRow[] = []
if (request && hasEnabledWorkspaceCleanupRule(nextRules)) {
// Project rules can delete with every global rule off, so the current rules are always read.
const previews = await Promise.all([previewRules(nextRules), previewRules(latest)]).catch(
(error: unknown) => {
toast.error(
t(
'components.settings.workspaceCleanup.previewFailed',
"Couldn't check which workspaces this change matches."
),
{ description: errorMessage(error) }
)
return null
}
)
const previews = await Promise.all([
previewRules(nextRules, { save: true }),
previewRules(latest)
]).catch((error: unknown) => {
toast.error(
t(
'components.settings.workspaceCleanup.previewFailed',
"Couldn't check which workspaces this change matches."
),
{ description: errorMessage(error) }
)
return null
})
if (!mountedRef.current) {
return
}
@@ -2,7 +2,10 @@ import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target'
import {
runtimeTargetEnvironmentId,
runtimeTargetForOwnerEnvironment
} from '@/runtime/rpc/targets/runtime-client-target'
import { onWorkspaceCleanupRunFinished } from '@/components/workspace-cleanup/events/workspace-cleanup-run-finished-window-event'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import { hasRuntimeRpcErrorCode } from '../../../../../shared/runtime-rpc-error-code'
@@ -37,7 +40,11 @@ export function useWorkspaceCleanupHostActivity(
preview: WorkspaceCleanupPreview | null
cleaningUp: boolean
cleanUpNow: () => Promise<void>
previewRules: (rules: WorkspaceCleanupRules) => Promise<WorkspaceCleanupPreview>
/** `save` marks the rules about to be saved, so their preview is kept as the page's matches. */
previewRules: (
rules: WorkspaceCleanupRules,
options?: { save?: boolean }
) => Promise<WorkspaceCleanupPreview>
} {
const t = useTranslate()
const environmentId = runtimeTargetEnvironmentId(owner)
@@ -69,10 +76,12 @@ export function useWorkspaceCleanupHostActivity(
)
useEffect(() => {
// Read once the rules are in: their arrival would otherwise read the report a second time.
if (!rulesKey) {
return
}
let current = true
const target: RuntimeClientTarget = environmentId
? { kind: 'environment', environmentId }
: { kind: 'local' }
const target = runtimeTargetForOwnerEnvironment(environmentId)
void callRuntimeRpc<WorkspaceCleanupReport>(target, 'workspaceCleanup.report', undefined, {
timeoutMs: REPORT_TIMEOUT_MS
}).then(
@@ -108,9 +117,7 @@ export function useWorkspaceCleanupHostActivity(
return
}
let current = true
const target: RuntimeClientTarget = environmentId
? { kind: 'environment', environmentId }
: { kind: 'local' }
const target = runtimeTargetForOwnerEnvironment(environmentId)
void callRuntimeRpc<WorkspaceCleanupPreview>(
target,
'workspaceCleanup.preview',
@@ -152,14 +159,19 @@ export function useWorkspaceCleanupHostActivity(
}, [owner, t])
const previewRules = useCallback(
async (nextRules: WorkspaceCleanupRules): Promise<WorkspaceCleanupPreview> => {
async (
nextRules: WorkspaceCleanupRules,
options?: { save?: boolean }
): Promise<WorkspaceCleanupPreview> => {
const preview = await callRuntimeRpc<WorkspaceCleanupPreview>(
owner,
'workspaceCleanup.preview',
{ rules: nextRules },
{ timeoutMs: EVALUATE_TIMEOUT_MS }
)
setPreviewState({ ownerKey, rulesKey: JSON.stringify(nextRules), revision, preview })
if (options?.save) {
setPreviewState({ ownerKey, rulesKey: JSON.stringify(nextRules), revision, preview })
}
return preview
},
[owner, ownerKey, revision]
@@ -30,7 +30,10 @@ import { WorkspaceCleanupMetadataText } from '@/components/workspace-cleanup/can
import type { WorkspaceCleanupKeepCause } from '../../../../../shared/workspace-cleanup/workspace-cleanup-evaluation'
import { formatCleanupKeepCause, formatCleanupRunTrigger } from './workspace-cleanup-run-labels'
import { WorkspaceCleanupReasonChip } from './workspace-cleanup-reason-chip'
import { WorkspaceCleanupWorkspaceLine } from './workspace-cleanup-workspace-line'
import {
WorkspaceCleanupWorkspaceLine,
workspaceCleanupRowKey
} from './workspace-cleanup-workspace-line'
const KEPT_PREVIEW_COUNT = 4
@@ -52,10 +55,6 @@ const KEEP_CAUSE_ICONS: Partial<Record<WorkspaceCleanupKeepCause, LucideIcon>> =
}
const REMOVED_GRID = 'grid grid-cols-[minmax(0,1fr)_9rem_4.5rem_4rem] items-center gap-x-4'
function rowKey(row: WorkspaceCleanupRunRow): string {
return `${row.executionHostId ?? ''}:${row.worktreeId}`
}
/** Each run's receipt, newest first; the newest opens expanded. */
export function WorkspaceCleanupActivityList({
runs
@@ -171,7 +170,7 @@ function ActivityRun({
})}
</GroupLabel>
{removed.map((row) => (
<RemovedRow key={rowKey(row)} row={row} />
<RemovedRow key={workspaceCleanupRowKey(row)} row={row} />
))}
</>
) : null}
@@ -184,7 +183,7 @@ function ActivityRun({
})}
</GroupLabel>
{failed.map((row) => (
<div key={rowKey(row)} className="min-w-0 py-1.5">
<div key={workspaceCleanupRowKey(row)} className="min-w-0 py-1.5">
<WorkspaceCleanupWorkspaceLine workspace={row} />
{row.error ? <ErrorLine message={row.error} /> : null}
</div>
@@ -250,7 +249,7 @@ function KeptRows({
</GroupLabel>
{visible.map((row) => (
<div
key={rowKey(row)}
key={workspaceCleanupRowKey(row)}
className="grid grid-cols-[minmax(0,1fr)_minmax(0,auto)] items-center gap-x-4 py-1.5"
>
<WorkspaceCleanupWorkspaceLine workspace={row} />
@@ -13,7 +13,10 @@ import {
DialogHeader,
DialogTitle
} from '../../ui/dialog'
import { WorkspaceCleanupWorkspaceLine } from './workspace-cleanup-workspace-line'
import {
WorkspaceCleanupWorkspaceLine,
workspaceCleanupRowKey
} from './workspace-cleanup-workspace-line'
import { WorkspaceCleanupReasonChip } from './workspace-cleanup-reason-chip'
export type WorkspaceCleanupRuleChangeRequest = {
@@ -23,10 +26,6 @@ export type WorkspaceCleanupRuleChangeRequest = {
rows: WorkspaceCleanupPreviewRow[]
}
function rowKey(row: WorkspaceCleanupPreviewRow): string {
return `${row.executionHostId ?? ''}:${row.worktreeId}`
}
/** Shown before a rule change that would delete workspaces right now; Keep pins a workspace so it is never removed. */
export function WorkspaceCleanupRuleChangeDialog({
request,
@@ -48,14 +47,14 @@ export function WorkspaceCleanupRuleChangeDialog({
}
const rows = shownRequest?.rows ?? []
const enabling = shownRequest?.kind !== 'change'
const deleted = rows.filter((row) => !kept.has(rowKey(row)))
const deleted = rows.filter((row) => !kept.has(workspaceCleanupRowKey(row)))
const deletedBytes = deleted.reduce((sum, row) => sum + (row.sizeBytes ?? 0), 0)
const toggleKeep = (row: WorkspaceCleanupPreviewRow): void => {
setKept((previous) => {
const next = new Set(previous)
if (!next.delete(rowKey(row))) {
next.add(rowKey(row))
if (!next.delete(workspaceCleanupRowKey(row))) {
next.add(workspaceCleanupRowKey(row))
}
return next
})
@@ -63,7 +62,9 @@ export function WorkspaceCleanupRuleChangeDialog({
const confirm = (): void => {
setConfirming(true)
void onConfirm(rows.filter((row) => kept.has(rowKey(row)))).finally(() => setConfirming(false))
void onConfirm(rows.filter((row) => kept.has(workspaceCleanupRowKey(row)))).finally(() =>
setConfirming(false)
)
}
return (
@@ -110,10 +111,10 @@ export function WorkspaceCleanupRuleChangeDialog({
className="max-h-72 overflow-y-auto rounded-md border border-border/70 bg-muted/35 px-3 scrollbar-sleek"
>
{rows.map((row, index) => {
const keep = kept.has(rowKey(row))
const keep = kept.has(workspaceCleanupRowKey(row))
return (
<div
key={rowKey(row)}
key={workspaceCleanupRowKey(row)}
role="listitem"
className={cn(
'flex min-w-0 items-center gap-3 py-2',
@@ -14,14 +14,17 @@ import {
NumberField,
SettingsBadge,
SettingsRow,
SettingsSegmentedControl
SettingsSegmentedControl,
SettingsSwitchRow
} from '../form-controls/SettingsFormControls'
import { SearchableSetting } from '../section-layout/SearchableSetting'
import { SettingsGroupDisclosure } from '../section-layout/SettingsGroupDisclosure'
import { matchesSettingsSearch } from '../search/settings-search'
import { Switch } from '../../ui/switch'
import { getWorkspaceCleanupSettingsSearchParts } from './workspace-cleanup-settings-search'
import { WorkspaceCleanupWorkspaceLine } from './workspace-cleanup-workspace-line'
import {
WorkspaceCleanupWorkspaceLine,
workspaceCleanupRowKey
} from './workspace-cleanup-workspace-line'
import type { WorkspaceCleanupRuleKey } from './workspace-cleanup-rule-keys'
export type WorkspaceCleanupRuleMatches = {
@@ -62,7 +65,7 @@ export function WorkspaceCleanupRuleRows({
const ruleRow = (key: WorkspaceCleanupRuleKey, description: string, badge?: string) => (
<SearchableSetting {...search[key]} forceVisible={forceVisible}>
<SettingsRow
<SettingsSwitchRow
label={
<span className="inline-flex items-center gap-2">
{search[key].title}
@@ -84,14 +87,10 @@ export function WorkspaceCleanupRuleRows({
) : null}
</>
}
control={
<Switch
checked={rules[key].enabled}
disabled={disabled}
onCheckedChange={() => onToggle(key)}
aria-label={search[key].title}
/>
}
checked={rules[key].enabled}
disabled={disabled}
onChange={() => onToggle(key)}
ariaLabel={search[key].title}
/>
{key === 'inactive' && rules.inactive.enabled ? (
<InactiveRuleOptions rules={rules} onChange={onChange} disabled={disabled} />
@@ -228,7 +227,7 @@ function RuleMatchesLine({
<span className="mt-1 block space-y-0.5">
{matches.rows.map((row) => (
<WorkspaceCleanupWorkspaceLine
key={`${row.executionHostId ?? ''}:${row.worktreeId}`}
key={workspaceCleanupRowKey(row)}
workspace={row}
trailing={row.sizeBytes !== undefined ? formatBytes(row.sizeBytes) : undefined}
/>
@@ -5,7 +5,10 @@ import type {
WorkspaceCleanupRuleReason
} from '../../../../../shared/workspace-cleanup/workspace-cleanup-evaluation'
import type { WorkspaceCleanupRunTrigger } from '../../../../../shared/workspace-cleanup/workspace-cleanup-run'
import { formatWorkspaceCleanupKeepCause } from '../../../../../shared/workspace-cleanup/workspace-cleanup-verdict-labels'
import {
formatWorkspaceCleanupKeepCause,
formatWorkspaceCleanupRuleReason
} from '../../../../../shared/workspace-cleanup/workspace-cleanup-verdict-labels'
// Localized counterparts of the CLI's verdict labels; a value from a newer host falls back to the CLI's words.
@@ -28,8 +31,8 @@ export function formatCleanupRuleReasonBadge(
'Orchestration worker'
)
}
// A newer host can send a reason this build has no label for.
return String(reason)
// A newer host can send a reason this build has no label for; the CLI's words beat nothing.
return formatWorkspaceCleanupRuleReason(reason)
}
export function formatCleanupKeepCause(
@@ -29,3 +29,10 @@ export function WorkspaceCleanupWorkspaceLine({
</span>
)
}
/** One key per workspace across hosts; a bare id can repeat on two hosts. */
export function workspaceCleanupRowKey(
row: Pick<WorkspaceCleanupWorkspaceRef, 'executionHostId' | 'worktreeId'>
): string {
return `${row.executionHostId ?? ''}:${row.worktreeId}`
}
@@ -65,12 +65,11 @@ export function installAppLifetimeIpcEvents(): () => void {
unsubs.push(
window.api.automations.onChanged((payload) => emitAutomationsChangedWindowEvent(payload))
)
const stopCleanupRunFinished = window.api.workspaceCleanup.onRunFinished?.((run) =>
emitWorkspaceCleanupRunFinishedWindowEvent({ environmentId: null, run })
unsubs.push(
window.api.workspaceCleanup.onRunFinished((run) =>
emitWorkspaceCleanupRunFinishedWindowEvent({ environmentId: null, run })
)
)
if (stopCleanupRunFinished) {
unsubs.push(stopCleanupRunFinished)
}
const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive)
const statusApi = window.api.runtimeEnvironments