feat(settings): add per-project workspace cleanup rules to project settings (#42055)

* feat(settings): add per-project workspace cleanup rules to project settings

A project's settings get a Workspace cleanup section: Use global, Off, or
Custom rules seeded from the host's global rules, using the same rule rows
as the Workspace Cleanup page.

* fix(settings): compare a cleanup change against a fresh preview, not the cached one

A cached baseline can predate files that now protect a workspace, which
would hide it from the confirm. The host shares concurrent previews, so a
fresh read stays one evaluation.
This commit is contained in:
Kelvin Amoaba
2026-11-14 00:05:03 +00:00
committed by GitHub
parent cfa6f0e5e3
commit fe138b0ece
12 changed files with 292 additions and 25 deletions
@@ -82,6 +82,7 @@ export type RepositoryApi = {
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
hiddenAt?: Repo['hiddenAt'] | null
ghAccount?: GhAccountBinding | null
workspaceCleanupPolicy?: Repo['workspaceCleanupPolicy'] | null
}
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
@@ -38,6 +38,7 @@ import { RepositoryWindowsRuntimeSection } from './runtime/RepositoryWindowsRunt
import { matchesRepositoryIdentitySearch } from './identity/repository-identity-search'
import { RepositoryWorktreeDefaultsSection } from './worktrees/RepositoryWorktreeDefaultsSection'
import { RepositoryJiraProjectSection } from './RepositoryJiraProjectSection'
import { RepositoryWorkspaceCleanupSection } from './workspace-cleanup/RepositoryWorkspaceCleanupSection'
import { getProjectRuntimeSessionSummary } from './runtime/repository-runtime-session-summary'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../store/slices/worktrees/listing/worktree-visibility-defaults-by-host'
import {
@@ -47,11 +48,12 @@ import {
type RepositoryPaneRepoUpdate = Omit<
Partial<Repo>,
'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount'
'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' | 'workspaceCleanupPolicy'
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
ghAccount?: GhAccountBinding | null
workspaceCleanupPolicy?: Repo['workspaceCleanupPolicy'] | null
}
const EMPTY_WSL_DISTROS: string[] = []
@@ -358,6 +360,12 @@ export function RepositoryPane({
) : null}
</SettingsGroup>
) : null,
<RepositoryWorkspaceCleanupSection
key={`workspace-cleanup:${selectedHostId}:${repo.id}`}
repo={repo}
updateRepo={updateSelectedRepo}
forceVisible={forceFullPaneForRepoMatch}
/>,
hooksSection,
forceFullPaneForRepoMatch ||
matchesSettingsSearch(searchQuery, [getRepositoryFormatterSearchEntry(t)]) ? (
@@ -18,6 +18,7 @@ import {
} from './worktrees/repository-git-worktree-search-entries'
import { getRepositoryFormatterSearchEntry } from './RepositoryFormatterSection'
import { getRepositoryAgentTrustSearchEntry } from './RepositoryAgentTrustSection'
import { getRepositoryWorkspaceCleanupSearchEntry } from './workspace-cleanup/repository-workspace-cleanup-search'
type RepositoryPaneSearchOptions = {
isLocalWindowsProject?: boolean
@@ -244,6 +245,7 @@ export function getRepositoryPaneSearchEntries(
}
if (!isFolderRepo(repo)) {
entries.push(...getRepositoryGitWorktreeSearchEntries(repo, t))
entries.push(getRepositoryWorkspaceCleanupSearchEntry(repo, t))
}
entries.push(parts.sidebarVisibility, parts.removeProject)
if (parts.sourceControlAi) {
@@ -0,0 +1,153 @@
import { useState } from 'react'
import type { Repo } from '../../../../../../shared/repo-types'
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
import {
withEveryRuleOff,
type RepoWorkspaceCleanupPolicy
} from '../../../../../../shared/workspace-cleanup/workspace-cleanup-policy'
import { useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
import { SettingsGroup } from '../../section-layout/SettingsGroup'
import { SearchableSetting } from '../../section-layout/SearchableSetting'
import { useWorkspaceCleanupHostRules } from '../../workspace-cleanup/use-workspace-cleanup-host-rules'
import { WorkspaceCleanupRuleRows } from '../../workspace-cleanup/workspace-cleanup-rule-rows'
import { setWorkspaceCleanupRuleEnabled } from '../../workspace-cleanup/workspace-cleanup-rule-keys'
import { formatWorkspaceCleanupRuleSummary } from '../../workspace-cleanup/workspace-cleanup-rule-summary'
import { getRepositoryWorkspaceCleanupSearchEntry } from './repository-workspace-cleanup-search'
import { isFolderRepo } from '../../../../../../shared/repo-kind'
import { isWebClientLocation } from '@/app-shell/platform/web-client-location'
import { matchesSettingsSearch } from '../../search/settings-search'
type PolicyChoice = 'global' | RepoWorkspaceCleanupPolicy['mode']
/** A project's own cleanup rules; absent follows the rules of the host that owns the project. */
export function RepositoryWorkspaceCleanupSection({
repo,
updateRepo,
forceVisible
}: {
repo: Repo
updateRepo: (
repoId: string,
updates: { workspaceCleanupPolicy: RepoWorkspaceCleanupPolicy | null }
) => void | Promise<boolean>
forceVisible: boolean
}): React.JSX.Element | null {
const t = useTranslate()
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings)
const owner = ipcOrRuntimeTargetForHostId(getRepoExecutionHostId(repo)) ?? { kind: 'local' }
const { hostRules } = useWorkspaceCleanupHostRules(owner, settings, updateSettings)
// Each write replaces the whole policy, so a second edit must wait for the first to land.
const [saving, setSaving] = useState(false)
const entry = getRepositoryWorkspaceCleanupSearchEntry(repo, t)
// Cleanup never removes a folder project's workspace, and a paired browser reads its own
// settings rather than the global rules of the host it pairs with.
if (
hostRules.state !== 'ready' ||
isFolderRepo(repo) ||
isWebClientLocation() ||
!(forceVisible || matchesSettingsSearch(searchQuery, entry))
) {
return null
}
const globalRules = hostRules.rules
const policy = repo.workspaceCleanupPolicy
const choice: PolicyChoice = policy?.mode ?? 'global'
const globalSummary = formatWorkspaceCleanupRuleSummary(globalRules, t)
const save = (workspaceCleanupPolicy: RepoWorkspaceCleanupPolicy | null): void => {
setSaving(true)
void Promise.resolve(updateRepo(repo.id, { workspaceCleanupPolicy })).finally(() =>
setSaving(false)
)
}
const choose = (next: string): void => {
if (next === choice) {
return
}
save(
next === 'off'
? { mode: 'off' }
: next === 'custom'
? // Custom starts from what the project followed, so switching changes nothing yet.
{
mode: 'custom',
rules: choice === 'off' ? withEveryRuleOff(globalRules) : globalRules
}
: null
)
}
return (
<SettingsGroup
title={t('components.settings.repository.workspaceCleanup.title', 'Workspace cleanup')}
description={t(
'components.settings.repository.workspaceCleanup.description',
'Rules for {{name}} only. Use global follows Settings / Workspace Cleanup.',
{ name: repo.displayName }
)}
>
<SearchableSetting {...entry} className="space-y-2 py-3" forceVisible={forceVisible}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold">{entry.title}</p>
<p className="text-xs text-muted-foreground">
{choice === 'global'
? t(
'components.settings.repository.workspaceCleanup.usingGlobal',
'Using global: {{rules}}',
{ rules: globalSummary }
)
: t(
'components.settings.repository.workspaceCleanup.globalDefault',
'Global default: {{rules}}',
{ rules: globalSummary }
)}
</p>
</div>
<Select value={choice} onValueChange={choose} disabled={saving}>
<SelectTrigger size="sm" className="h-8 w-[132px]" aria-label={entry.title}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
{t('components.settings.repository.workspaceCleanup.useGlobal', 'Use global')}
</SelectItem>
<SelectItem value="off">
{t('components.settings.repository.workspaceCleanup.off', 'Off')}
</SelectItem>
<SelectItem value="custom">
{t('components.settings.repository.workspaceCleanup.custom', 'Custom')}
</SelectItem>
</SelectContent>
</Select>
</div>
{policy?.mode === 'custom' ? (
<div className="ml-4 border-l border-border pl-4">
<WorkspaceCleanupRuleRows
rules={policy.rules}
forceVisible
disabled={saving}
onToggle={(key) =>
save({
mode: 'custom',
rules: setWorkspaceCleanupRuleEnabled(
policy.rules,
key,
!policy.rules[key].enabled
)
})
}
onChange={(change) => save({ mode: 'custom', rules: change(policy.rules) })}
/>
</div>
) : null}
</SearchableSetting>
</SettingsGroup>
)
}
@@ -0,0 +1,34 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { SettingsSearchEntry } from '../../search/settings-search'
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import { searchKeywordTranslator } from '../../search/settings-search-keywords'
export function getRepositoryWorkspaceCleanupSearchEntry(
repo: Repo,
t: Translate = translate
): SettingsSearchEntry {
const translateSearchKeyword = searchKeywordTranslator(t)
return {
title: t('components.settings.repository.workspaceCleanup.rules', 'Cleanup rules'),
description: t(
'components.settings.repository.workspaceCleanup.rulesDescription',
'Which workspace cleanup rules apply to this project.'
),
keywords: [
repo.displayName,
...translateSearchKeyword(
'components.settings.repository.workspaceCleanup.keywordCleanup',
'cleanup'
),
...translateSearchKeyword(
'components.settings.repository.workspaceCleanup.keywordDelete',
'delete'
),
...translateSearchKeyword(
'components.settings.repository.workspaceCleanup.keywordMerged',
'merged'
)
]
}
}
@@ -105,7 +105,7 @@ export function WorkspaceCleanupSettingsPane({
updateSettings
)
const rules = hostRules.state === 'ready' ? hostRules.rules : null
const { hostReport, preview, cleaningUp, cleanUpNow, previewRules, cachedPreview, adoptPreview } =
const { hostReport, preview, cleaningUp, cleanUpNow, previewRules, adoptPreview } =
useWorkspaceCleanupHostActivity(owner, rules)
const matches = useMemo(() => summarizeMatches(preview), [preview])
const [pendingRule, setPendingRule] = useState<WorkspaceCleanupRuleKey | null>(null)
@@ -228,21 +228,20 @@ export function WorkspaceCleanupSettingsPane({
rows: WorkspaceCleanupPreviewRow[]
nextPreview: WorkspaceCleanupPreview
} | null> => {
// Project rules can delete with every global rule off, so the current rules are always read,
// from the page's own preview when it already covers them.
const previews = await Promise.all([
previewRules(nextRules),
cachedPreview(latest) ?? 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
})
// Read fresh: a cached baseline can predate files that now protect a workspace. 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
}
)
return previews
? { rows: newRemovals(previews[0], previews[1]), nextPreview: previews[0] }
: null
@@ -41,8 +41,6 @@ export function useWorkspaceCleanupHostActivity(
cleaningUp: boolean
cleanUpNow: () => Promise<void>
previewRules: (rules: WorkspaceCleanupRules) => Promise<WorkspaceCleanupPreview>
/** The page's own preview when it already evaluated exactly these rules, so callers skip a host scan. */
cachedPreview: (rules: WorkspaceCleanupRules) => WorkspaceCleanupPreview | null
/** Makes a preview of rules that were just saved the page's matches, instead of scanning them again. */
adoptPreview: (rules: WorkspaceCleanupRules, preview: WorkspaceCleanupPreview) => void
} {
@@ -178,10 +176,6 @@ export function useWorkspaceCleanupHostActivity(
cleaningUp,
cleanUpNow,
previewRules,
cachedPreview: (candidate) =>
previewIsCurrent && previewState?.rulesKey === JSON.stringify(candidate)
? previewState.preview
: null,
adoptPreview: (savedRules, preview) =>
setPreviewState({ ownerKey, rulesKey: JSON.stringify(savedRules), revision, preview })
}
@@ -0,0 +1,42 @@
import type { Translate } from '@/i18n/use-translate'
import {
WORKSPACE_ARCHIVE_SUPPORTED,
type WorkspaceCleanupRules
} from '../../../../../shared/workspace-cleanup/workspace-cleanup-policy'
/** The rules that are on, in a few words each, for a one-line description. */
export function formatWorkspaceCleanupRuleSummary(
rules: WorkspaceCleanupRules,
t: Translate
): string {
const parts: string[] = []
if (rules.merged.enabled) {
parts.push(t('components.settings.workspaceCleanup.summary.merged', 'Delete merged'))
}
if (rules.unchanged.enabled) {
parts.push(t('components.settings.workspaceCleanup.summary.unchanged', 'Delete unchanged'))
}
if (rules.inactive.enabled) {
parts.push(
WORKSPACE_ARCHIVE_SUPPORTED
? t('components.settings.workspaceCleanup.summary.inactiveCleanUp', 'Clean up inactive')
: t('components.settings.workspaceCleanup.summary.inactive', 'Delete inactive')
)
}
if (rules.automationRuns.enabled) {
parts.push(
t('components.settings.workspaceCleanup.summary.automationRuns', 'Automation workspaces')
)
}
if (rules.orchestrationWorkers.enabled) {
parts.push(
t(
'components.settings.workspaceCleanup.summary.orchestrationWorkers',
'Orchestration workers'
)
)
}
return parts.length > 0
? parts.join(', ')
: t('components.settings.workspaceCleanup.summary.off', 'Off')
}
+26 -1
View File
@@ -22367,7 +22367,32 @@
"apply": "Apply"
},
"rulesLoadFailed": "Couldn't load this host's cleanup rules: {{error}}",
"retry": "Try again"
"retry": "Try again",
"summary": {
"merged": "Delete merged",
"unchanged": "Delete unchanged",
"inactiveCleanUp": "Clean up inactive",
"inactive": "Delete inactive",
"automationRuns": "Automation workspaces",
"orchestrationWorkers": "Orchestration workers",
"off": "Off"
}
},
"repository": {
"workspaceCleanup": {
"title": "Workspace cleanup",
"description": "Rules for {{name}} only. Use global follows Settings / Workspace Cleanup.",
"usingGlobal": "Using global: {{rules}}",
"globalDefault": "Global default: {{rules}}",
"useGlobal": "Use global",
"off": "Off",
"custom": "Custom",
"rules": "Cleanup rules",
"rulesDescription": "Which workspace cleanup rules apply to this project.",
"keywordCleanup": "cleanup",
"keywordDelete": "delete",
"keywordMerged": "merged"
}
}
},
"agentChildRow": {
@@ -73,6 +73,7 @@ export type RepoUpdate = Partial<
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
hiddenAt?: Repo['hiddenAt'] | null
ghAccount?: GhAccountBinding | null
workspaceCleanupPolicy?: Repo['workspaceCleanupPolicy'] | null
}
export type ProjectUpdate = ProjectUpdateArgs['updates']
@@ -200,6 +200,7 @@ export function createRepoUpdateActions(
ghAccount,
externalWorktreeVisibility,
agentWorktreeVisibility,
workspaceCleanupPolicy,
...updatesWithoutClearSentinels
} = sanitizedUpdates
mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels }
@@ -245,6 +246,12 @@ export function createRepoUpdateActions(
} else if (ghAccount !== undefined) {
mergedRepo = { ...mergedRepo, ghAccount }
}
if (workspaceCleanupPolicy === null) {
const { workspaceCleanupPolicy: _policy, ...repoWithoutCleanupPolicy } = mergedRepo
mergedRepo = repoWithoutCleanupPolicy
} else if (workspaceCleanupPolicy !== undefined) {
mergedRepo = { ...mergedRepo, workspaceCleanupPolicy }
}
return s.githubSourceCollections?.adopt(mergedRepo) ?? mergedRepo
})
return {
@@ -138,7 +138,8 @@ export function normalizeRepoWorkspaceCleanupPolicy(
return undefined
}
function withEveryRuleOff(rules: WorkspaceCleanupRules): WorkspaceCleanupRules {
/** The same rules with every rule off; thresholds and protections are kept. */
export function withEveryRuleOff(rules: WorkspaceCleanupRules): WorkspaceCleanupRules {
return {
merged: { enabled: false },
unchanged: { ...rules.unchanged, enabled: false },