diff --git a/src/preload/api/workspaces/workspace-cleanup-api.ts b/src/preload/api/workspaces/workspace-cleanup-api.ts index 148de5943a4..a16c8d7b7b2 100644 --- a/src/preload/api/workspaces/workspace-cleanup-api.ts +++ b/src/preload/api/workspaces/workspace-cleanup-api.ts @@ -29,7 +29,7 @@ export type WorkspaceCleanupApi = { recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise /** 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 = { diff --git a/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx b/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx index 2ac4e646515..b48442a0b4a 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx +++ b/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx @@ -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 } diff --git a/src/renderer/src/components/settings/workspace-cleanup/use-workspace-cleanup-host-activity.ts b/src/renderer/src/components/settings/workspace-cleanup/use-workspace-cleanup-host-activity.ts index 8cccc37c018..415d1767c48 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/use-workspace-cleanup-host-activity.ts +++ b/src/renderer/src/components/settings/workspace-cleanup/use-workspace-cleanup-host-activity.ts @@ -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 - previewRules: (rules: WorkspaceCleanupRules) => Promise + /** `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 } { 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(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( target, 'workspaceCleanup.preview', @@ -152,14 +159,19 @@ export function useWorkspaceCleanupHostActivity( }, [owner, t]) const previewRules = useCallback( - async (nextRules: WorkspaceCleanupRules): Promise => { + async ( + nextRules: WorkspaceCleanupRules, + options?: { save?: boolean } + ): Promise => { const preview = await callRuntimeRpc( 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] diff --git a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-activity-list.tsx b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-activity-list.tsx index b643bc0162a..f636183e954 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-activity-list.tsx +++ b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-activity-list.tsx @@ -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> = } 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({ })} {removed.map((row) => ( - + ))} ) : null} @@ -184,7 +183,7 @@ function ActivityRun({ })} {failed.map((row) => ( -
+
{row.error ? : null}
@@ -250,7 +249,7 @@ function KeptRows({ {visible.map((row) => (
diff --git a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-change-dialog.tsx b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-change-dialog.tsx index fed0c4dd414..d21d5071a60 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-change-dialog.tsx +++ b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-change-dialog.tsx @@ -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 (
( - {search[key].title} @@ -84,14 +87,10 @@ export function WorkspaceCleanupRuleRows({ ) : null} } - control={ - 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 ? ( @@ -228,7 +227,7 @@ function RuleMatchesLine({ {matches.rows.map((row) => ( diff --git a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-run-labels.ts b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-run-labels.ts index 1f64e07802a..931c5a79e63 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-run-labels.ts +++ b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-run-labels.ts @@ -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( diff --git a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-workspace-line.tsx b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-workspace-line.tsx index 0124367b71d..f795c3e35ef 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-workspace-line.tsx +++ b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-workspace-line.tsx @@ -29,3 +29,10 @@ export function WorkspaceCleanupWorkspaceLine({ ) } + +/** One key per workspace across hosts; a bare id can repeat on two hosts. */ +export function workspaceCleanupRowKey( + row: Pick +): string { + return `${row.executionHostId ?? ''}:${row.worktreeId}` +} diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 5943d3cb20e..d282411ced0 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -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