mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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 = {
|
||||
|
||||
+21
-26
@@ -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
|
||||
}
|
||||
|
||||
+22
-10
@@ -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]
|
||||
|
||||
+7
-8
@@ -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} />
|
||||
|
||||
+12
-11
@@ -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',
|
||||
|
||||
+12
-13
@@ -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}
|
||||
/>
|
||||
|
||||
+6
-3
@@ -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(
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user