mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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) {
|
||||
|
||||
+153
@@ -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>
|
||||
)
|
||||
}
|
||||
+34
@@ -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'
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
+15
-16
@@ -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
|
||||
|
||||
-6
@@ -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 })
|
||||
}
|
||||
|
||||
+42
@@ -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')
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user