Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions CleanMac/Views/DiskAnalysisView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct DiskAnalysisView: View {
@State private var statusMessage: String?
@State private var problemMessage: String?
@State private var isScanning = false
@State private var isChoosingCustomFolder = false
@State private var activeScanID: UUID?
@State private var workerTask: Task<DiskAnalysisReport, Error>?
@State private var coordinatorTask: Task<Void, Never>?
Expand Down Expand Up @@ -133,11 +134,8 @@ struct DiskAnalysisView: View {
}
}
}
.onChange(of: source) { _, newSource in
.onChange(of: source) { _, _ in
resetResults()
if newSource == .custom, customFolderURL == nil {
chooseCustomFolder()
}
}
.onChange(of: threshold) { _, _ in
keepOnlyVisibleSelection()
Expand Down Expand Up @@ -179,24 +177,25 @@ struct DiskAnalysisView: View {
}

private var sourcePicker: some View {
Picker(L.t("disk.source.title"), selection: $source) {
Picker(L.t("disk.source.title"), selection: sourceSelection) {
ForEach(DiskAnalysisSource.allCases) { source in
Text(source.title).tag(source)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 560)
.disabled(isScanning || isChoosingCustomFolder)
}

private var sourceActions: some View {
HStack(spacing: 10) {
if source == .custom {
Button {
chooseCustomFolder()
presentCustomFolderPicker()
} label: {
Label(L.t("disk.source.choose"), systemImage: "folder.badge.plus")
}
.disabled(isScanning)
.disabled(isScanning || isChoosingCustomFolder)
}

if isScanning {
Expand All @@ -212,7 +211,7 @@ struct DiskAnalysisView: View {
Label(L.t("disk.scan.button"), systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(sourceURL == nil)
.disabled(sourceURL == nil || isChoosingCustomFolder)
}
}
}
Expand Down Expand Up @@ -589,17 +588,37 @@ struct DiskAnalysisView: View {
}
}

private func chooseCustomFolder() {
guard let selectedURL = DiskAnalysisWorkspaceService.chooseFolder() else {
if customFolderURL == nil, source == .custom {
source = .home
private var sourceSelection: Binding<DiskAnalysisSource> {
Binding(
get: { source },
set: { newSource in
guard newSource != source else { return }
if newSource == .custom, customFolderURL == nil {
presentCustomFolderPicker()
} else {
source = newSource
}
}
return
}
)
}

private func presentCustomFolderPicker() {
guard !isScanning, !isChoosingCustomFolder else { return }
isChoosingCustomFolder = true

Task { @MainActor in
await Task.yield()
let selectedURL = DiskAnalysisWorkspaceService.chooseFolder()
isChoosingCustomFolder = false
guard let selectedURL else { return }

customFolderURL = selectedURL
source = .custom
resetResults()
let wasCustomSource = source == .custom
customFolderURL = selectedURL
source = .custom
if wasCustomSource {
resetResults()
}
}
}

private func startScan() {
Expand Down
53 changes: 36 additions & 17 deletions CleanMac/Views/DuplicateFinderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct DuplicateFinderView: View {
@State private var problemMessage: String?
@State private var isScanning = false
@State private var isCleaning = false
@State private var isChoosingCustomFolder = false
@State private var showingCleanupConfirmation = false
@State private var activeScanID: UUID?
@State private var workerTask: Task<DuplicateScanReport, Error>?
Expand Down Expand Up @@ -97,11 +98,8 @@ struct DuplicateFinderView: View {
}
}
}
.onChange(of: source) { _, newSource in
.onChange(of: source) { _, _ in
resetResults()
if newSource == .custom, customFolderURL == nil {
chooseCustomFolder()
}
}
.onChange(of: includeLargeFiles) { _, _ in
resetResults()
Expand Down Expand Up @@ -170,25 +168,25 @@ struct DuplicateFinderView: View {
}

private var sourcePicker: some View {
Picker(L.t("duplicates.source.title"), selection: $source) {
Picker(L.t("duplicates.source.title"), selection: sourceSelection) {
ForEach(DuplicateSearchSource.allCases) { source in
Text(source.title).tag(source)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 480)
.disabled(isScanning || isCleaning)
.disabled(isScanning || isCleaning || isChoosingCustomFolder)
}

private var sourceActions: some View {
HStack(spacing: 10) {
if source == .custom {
Button {
chooseCustomFolder()
presentCustomFolderPicker()
} label: {
Label(L.t("duplicates.source.choose"), systemImage: "folder.badge.plus")
}
.disabled(isScanning || isCleaning)
.disabled(isScanning || isCleaning || isChoosingCustomFolder)
}

if isScanning {
Expand All @@ -204,7 +202,7 @@ struct DuplicateFinderView: View {
Label(L.t("duplicates.scan.button"), systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(sourceURL == nil || isCleaning)
.disabled(sourceURL == nil || isCleaning || isChoosingCustomFolder)
}
}
}
Expand Down Expand Up @@ -435,16 +433,37 @@ struct DuplicateFinderView: View {
}
}

private func chooseCustomFolder() {
guard let selectedURL = DuplicateWorkspaceService.chooseFolder() else {
if customFolderURL == nil, source == .custom {
source = .downloads
private var sourceSelection: Binding<DuplicateSearchSource> {
Binding(
get: { source },
set: { newSource in
guard newSource != source else { return }
if newSource == .custom, customFolderURL == nil {
presentCustomFolderPicker()
} else {
source = newSource
}
}
)
}

private func presentCustomFolderPicker() {
guard !isScanning, !isCleaning, !isChoosingCustomFolder else { return }
isChoosingCustomFolder = true

Task { @MainActor in
await Task.yield()
let selectedURL = DuplicateWorkspaceService.chooseFolder()
isChoosingCustomFolder = false
guard let selectedURL else { return }

let wasCustomSource = source == .custom
customFolderURL = selectedURL
source = .custom
if wasCustomSource {
resetResults()
}
return
}
customFolderURL = selectedURL
source = .custom
resetResults()
}

private func startScan() {
Expand Down
48 changes: 24 additions & 24 deletions contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,52 @@

## Task

- ID: TASK-042
- Title: Idempotent GitHub Release publishing
- ID: TASK-043
- Title: Fix custom-folder source selection
- Mode: continue

## Planner Notes

- Why this task now: the `v0.3.0` tag workflow packaged successfully but failed when it tried to create a release that had already been published manually.
- Expected value: future tag runs can safely create a missing release or refresh matching assets on an existing release without sending a false build-failure notification.
- Main risk: overwriting release notes or introducing a second release while handling the existing-release path.
- Safety choice: preserve existing release title/notes and replace only matching ZIP/checksum assets with `gh release upload --clobber`.
- Why this task now: the user reported that the Custom Folder source looked inactive in both read-only analysis workspaces.
- Expected value: reliable native folder selection without forcing a scan or losing the current built-in source on cancel.
- Main risk: opening a synchronous AppKit modal during a SwiftUI Picker state transaction can drop or visually revert the selection.
- Safety choice: intercept the custom value, defer the panel one main-actor turn, and change source state only after selection.

## Builder Scope

- Allowed files:
- `.github/workflows/release.yml`;
- `CleanMac/Views/DiskAnalysisView.swift`;
- `CleanMac/Views/DuplicateFinderView.swift`;
- Loop documentation files.
- Allowed commands:
- read-only Actions run/log/release inspection;
- YAML and embedded shell syntax checks;
- execute the approved existing-release branch against `v0.3.0` with the already verified local assets;
- source inspection and Debug build/launch;
- live native panel open/cancel/select checks without starting a scan;
- `swift test --package-path CleanMacCore`;
- standard Git/PR/CI checks and merge after green status.
- Out of scope:
- moving or recreating `v0.3.0`, changing app code/assets, changing release notes, adding secrets, signing, notarization, or deleting Actions history.
- scanning user folders, cleanup, release/version/package changes, localization, dependencies, or architecture changes.
- Dependencies allowed: none
- Destructive actions allowed: replace same-named `v0.3.0` release assets only; no user files
- Destructive actions allowed: none

## Evaluator Checklist

- Done criteria:
- Missing releases still use `gh release create` with the existing title and notes.
- Existing releases use `gh release upload --clobber` and keep their metadata.
- The exact existing-release shell path succeeds for `v0.3.0` and leaves exactly the verified ZIP and checksum assets.
- Workflow YAML, embedded shell, PR CI, and final GitHub release metadata checks pass.
- Both Custom Folder segments open their localized native folder panel.
- Cancel keeps the previous built-in source and leaves controls active.
- Selecting a folder activates Custom Folder, shows the exact path, and exposes the change-folder button.
- No scan starts from source selection alone.
- Required verification:
- Ruby YAML parse;
- extracted publish-step `bash -n`;
- controlled existing-release execution for `v0.3.0`;
- clean downloaded SHA-256 verification;
- Debug build and signed launch verification;
- live cancel/select interactions in both sections;
- `swift test --package-path CleanMacCore`;
- `git diff --check`;
- green GitHub PR checks.
- Manual checks:
- Confirm `v0.3.0` remains public/latest with its English release notes unchanged.
- Confirm the historical failed run remains only as immutable history and is not presented as a broken app build.
- Confirm the selected path is visible and no progress indicator appears.
- Do not start scanning or move any user file during verification.

## Result

- Status: complete
- Verification result: workflow YAML parsed; embedded shell passed `bash -n`; the stubbed missing-release path called `gh release create`; the exact existing-release path successfully refreshed `v0.3.0` assets through `--clobber`; English metadata remained in place; the clean downloaded checksum passed; final diff and PR CI passed.
- Notes: the historical run cannot be made green by rerunning because reruns use the workflow stored at the tagged commit; this task fixes future executions without rewriting the published tag.
- Verification result: Debug build and signed launch passed; both native panels opened; cancel preserved Downloads in Duplicate Finder; selecting `/Users/admin/Documents` activated Custom Folder and displayed the path in both sections; no scan started; core tests, diff checks, and PR CI passed.
- Notes: the fix changes only source-selection timing and does not expand folder access or scanner scope.
10 changes: 10 additions & 0 deletions progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,13 @@ Append-only history. Do not erase previous entries.
- Next step: Use the normal tag workflow for the next version; no manual pre-creation is required, but the workflow now safely tolerates it.
- Bottleneck: The historical `v0.3.0` failure cannot be turned green without rewriting the tag because GitHub reruns the workflow definition stored at that tagged commit.
- Handoff: No release tag, note, app binary, security setting, secret, or user file was changed; only same-named verified release assets were refreshed.

## 2026-07-12 - TASK-043 - Fix custom-folder source selection

- What changed: Replaced direct Picker state binding with an intercepting Binding in Disk Analysis and Duplicate Finder. Selecting Custom Folder now schedules the native folder panel after the current SwiftUI update, prevents duplicate panel requests, leaves the previous source selected on cancel, and activates Custom Folder only after a real selection. Source controls remain disabled only while scanning, cleaning, or the panel is open.
- Files touched: `CleanMac/Views/DiskAnalysisView.swift`, `CleanMac/Views/DuplicateFinderView.swift`, `contract.md`, `project-analysis.md`, `roadmap.md`, `progress.md`, `trace.md`.
- Checks run: Debug `xcodebuild`; `./script/build_and_run.sh --verify`; live Russian accessibility interaction in both sections; Duplicate Finder cancel path; `/Users/admin/Documents` selection in both sections without starting scans; `swift test --package-path CleanMacCore`; `git diff --check`.
- Result: Passed. Both Custom Folder segments open the native panel, the selected path appears with the segment active, and neither analysis nor duplicate scanning starts automatically.
- Next step: Package a 0.3.1 bug-fix release only when explicitly requested.
- Bottleneck: none; the known CoreSimulator warning remains unrelated and non-blocking for macOS builds.
- Handoff: CleanMac is running from the current Debug product on Duplicate Finder with `/Users/admin/Documents` selected. No cleanup, duplicate move, or scan was triggered.
1 change: 1 addition & 0 deletions project-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa
- Advanced developer cleanup uses exact allowlists for Homebrew, pip, Cargo registry cache/source, Gradle, Cursor/VS Code caches, Codex/Claude cache/temp folders, Xcode DeviceSupport, Previews, old user Simulator data, and individual Xcode Archives. IDE settings/extensions and AI projects/sessions/history/memory are excluded; Simulator and Archives require review, and Archives are never selected by default.
- Disk Analysis is a separate read-only workspace with whole-disk (`/` with no path exclusions), Home, Downloads, and custom-folder sources. One cancellable scan powers a bounded multi-ring folder map plus a non-selected large-file list with 50 MB, 100 MB, 500 MB, and 1 GB filters, size/date/type sorting, and Finder/Open actions. Its data never enters cleanup reports, junk totals, history, or scheduled scans.
- Duplicate Finder is a separate Home, Downloads, or custom-folder workspace. It narrows candidates by logical size and a first-block SHA-256 before streaming the full SHA-256 only for survivors, excludes hard links, limits hashing concurrency, protects one deterministic original in every group, starts with no selected copies, and moves only explicitly confirmed unchanged copies to Trash. Standard mode reports matching-size files over 500 MiB without hiding them; an optional slow mode hashes them.
- Disk Analysis and Duplicate Finder intercept their Custom Folder segment before changing source state, present the native folder panel on the next main-actor turn, preserve the previous source on cancel, and activate the custom source only after a real folder selection.
- Results UI is backed by real scanner output, safe results are selected by default, and cleanup requires explicit confirmation.
- Safe Mode now keeps review-risk results visible but unselectable, clears stale review selections when enabled, and rechecks risk immediately before cleanup execution.
- Results now explain why each item was suggested, using structured reasons produced by the scanner rules.
Expand Down
14 changes: 14 additions & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Roadmap

- [x] ID: TASK-043
Title: Fix custom-folder source selection
Goal: Make the Custom Folder source reliably open the native folder picker in both Disk Analysis and Duplicate Finder.
What to do: Decouple `NSOpenPanel.runModal()` from the segmented Picker update transaction, keep the previous source on cancel, and apply the custom source only after a folder is selected.
Files: `CleanMac/Views/DiskAnalysisView.swift`, `CleanMac/Views/DuplicateFinderView.swift`, Loop docs
Definition of done: both Custom Folder segments open the picker; cancel keeps the previous source; selecting a folder activates Custom Folder and displays its path; no scan starts automatically.
Verification: Debug build; `./script/build_and_run.sh --verify`; live Russian interaction check for cancel and select paths in both sections; `swift test --package-path CleanMacCore`; `git diff --check`
Priority: high
Impact: high
Risk: low
Effort: small
Confidence: high
Score: high impact / low risk / small

- [x] ID: TASK-042
Title: Idempotent GitHub Release publishing
Goal: Prevent a successful tag package from reporting failure when the corresponding GitHub Release already exists.
Expand Down
7 changes: 7 additions & 0 deletions trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,10 @@ Append-only trace of failures, restarts, and judgment divergences.
- Cause: the workflow always called `gh release create`, while `v0.3.0` had already been published and verified through the approved local release flow.
- Fix: query the tag first; create only when missing, otherwise upload the same ZIP/checksum names with `--clobber` while preserving the current title and English notes.
- Status: resolved for future executions; the exact existing-release branch refreshed both `v0.3.0` assets successfully and clean-download SHA-256 verification passed. The historical failed run remains unchanged because reruns use the workflow stored at the tagged commit.

## 2026-07-12 - TASK-043 - Custom folder segment did not open

- Symptom: clicking `Своя папка` in Disk Analysis or Duplicate Finder did not reliably activate the source or show the native folder picker.
- Cause: both views called synchronous `NSOpenPanel.runModal()` directly from the segmented Picker's `onChange` callback while SwiftUI was still applying the source-state transaction.
- Fix: use an intercepting source Binding, defer panel presentation to the next main-actor turn, and mutate the source only after a folder is selected; cancel leaves the previous source unchanged.
- Status: resolved; live Russian checks opened both native panels, verified cancel fallback, selected `/Users/admin/Documents` in both sections, and confirmed that no scan started automatically.