diff --git a/src/preload/api/projects/repository-api.ts b/src/preload/api/projects/repository-api.ts index 376ef2812df..13ab5b55ac1 100644 --- a/src/preload/api/projects/repository-api.ts +++ b/src/preload/api/projects/repository-api.ts @@ -82,6 +82,7 @@ export type RepositoryApi = { externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null hiddenAt?: Repo['hiddenAt'] | null ghAccount?: GhAccountBinding | null + workspaceCleanupPolicy?: Repo['workspaceCleanupPolicy'] | null } }) => Promise pickFolder: () => Promise diff --git a/src/renderer/src/components/settings/repository/RepositoryPane.tsx b/src/renderer/src/components/settings/repository/RepositoryPane.tsx index 290827dd4fd..4eb27dea570 100644 --- a/src/renderer/src/components/settings/repository/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/repository/RepositoryPane.tsx @@ -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, - '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} ) : null, + , hooksSection, forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, [getRepositoryFormatterSearchEntry(t)]) ? ( diff --git a/src/renderer/src/components/settings/repository/repository-search.ts b/src/renderer/src/components/settings/repository/repository-search.ts index ae3cbd208e3..fa38349aeca 100644 --- a/src/renderer/src/components/settings/repository/repository-search.ts +++ b/src/renderer/src/components/settings/repository/repository-search.ts @@ -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) { diff --git a/src/renderer/src/components/settings/repository/workspace-cleanup/RepositoryWorkspaceCleanupSection.tsx b/src/renderer/src/components/settings/repository/workspace-cleanup/RepositoryWorkspaceCleanupSection.tsx new file mode 100644 index 00000000000..7c267359a1f --- /dev/null +++ b/src/renderer/src/components/settings/repository/workspace-cleanup/RepositoryWorkspaceCleanupSection.tsx @@ -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 + 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 ( + + +
+
+

{entry.title}

+

+ {choice === 'global' + ? t( + 'components.settings.repository.workspaceCleanup.usingGlobal', + 'Using global: {{rules}}', + { rules: globalSummary } + ) + : t( + 'components.settings.repository.workspaceCleanup.globalDefault', + 'Global default: {{rules}}', + { rules: globalSummary } + )} +

+
+ +
+ {policy?.mode === 'custom' ? ( +
+ + save({ + mode: 'custom', + rules: setWorkspaceCleanupRuleEnabled( + policy.rules, + key, + !policy.rules[key].enabled + ) + }) + } + onChange={(change) => save({ mode: 'custom', rules: change(policy.rules) })} + /> +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/repository/workspace-cleanup/repository-workspace-cleanup-search.ts b/src/renderer/src/components/settings/repository/workspace-cleanup/repository-workspace-cleanup-search.ts new file mode 100644 index 00000000000..b98d11143f6 --- /dev/null +++ b/src/renderer/src/components/settings/repository/workspace-cleanup/repository-workspace-cleanup-search.ts @@ -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' + ) + ] + } +} diff --git a/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx b/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx index c7efbd5131f..f2bb3a32484 100644 --- a/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx +++ b/src/renderer/src/components/settings/workspace-cleanup/WorkspaceCleanupSettingsPane.tsx @@ -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(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 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 cbba92d0689..fab1eb95e00 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 @@ -41,8 +41,6 @@ export function useWorkspaceCleanupHostActivity( cleaningUp: boolean cleanUpNow: () => Promise previewRules: (rules: WorkspaceCleanupRules) => Promise - /** 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 }) } diff --git a/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-summary.ts b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-summary.ts new file mode 100644 index 00000000000..6f7a96a4ec0 --- /dev/null +++ b/src/renderer/src/components/settings/workspace-cleanup/workspace-cleanup-rule-summary.ts @@ -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') +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a0ea1b20375..3f328ae6c92 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index cb23dc2eaec..b187da15444 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -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'] diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index dc33f9eab6c..4fff17fa3e6 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -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 { diff --git a/src/shared/workspace-cleanup/workspace-cleanup-policy.ts b/src/shared/workspace-cleanup/workspace-cleanup-policy.ts index ed28fcd384e..2c081d742a6 100644 --- a/src/shared/workspace-cleanup/workspace-cleanup-policy.ts +++ b/src/shared/workspace-cleanup/workspace-cleanup-policy.ts @@ -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 },