diff --git a/docs-ai/051-repository-icon-detection/000-plan.md b/docs-ai/051-repository-icon-detection/000-plan.md new file mode 100644 index 00000000..2c44f710 --- /dev/null +++ b/docs-ai/051-repository-icon-detection/000-plan.md @@ -0,0 +1,184 @@ +# 051 — Repository Icon Detection on Add: Plan + +| | | +| --- | --- | +| **Status** | In progress — prerequisite met: [GlyphonKit](https://github.com/onevcat/GlyphonKit) `v0.1.0` published | +| **Anchor date** | 2026-07-25 | +| **Primary PRs** | — | +| **Related** | [025-repo-identity-appearance](../025-repo-identity-appearance/000-plan.md), [040-automatic-open-in](../040-automatic-open-in/000-plan.md), [044-foundation-model-branch-names](../044-foundation-model-branch-names/000-plan.md), issue #525 | + +## Background + +Prowl supports a manually selected per-repository icon, but repositories without one remain +text-only. Issue #525 proposes a useful default: inspect a repository once when it is added, +and use a local project icon only when the evidence is strong. This is deliberately not the +GitHub owner-avatar fallback proposed in #524: users commonly work under one or two owners, +so repeated avatars add visual noise without identifying the project. + +The existing automatic **Open In** path is relevant but not sufficient on its own. +`WorktreeProjectKind` performs one shallow listing and resolves one primary ecosystem for +`OpenWorktreeAction`; its ordered markers are intentionally biased towards a suitable editor. +It recognizes Apple, Android, .NET, Java, Go, Rust, C++, PHP, Ruby, Python, and generic web +projects. It does not establish that an image asset is an application icon, handle hybrid +projects such as Flutter, or retain enough evidence for a confidence decision. + +`RepositoryAppearance` already persists an SF Symbol or imported local image, and +`RepositoryIconAssetStore` already copies an image into Prowl-owned per-repository storage. +The detector should build on those rendering and persistence paths; it must not render from +an untrusted project file in place. + +The supplied PR #583 is unrelated: it is a read-only mobile remote-control bridge. The +relevant prior work is issue #525 and closed PR #524. + +## Goals + +- Make a newly added repository easier to identify with a meaningful, local, high-confidence + icon when one exists. +- Keep add/open interaction responsive. Detection never blocks repository loading, selection, + terminal creation, or the main actor. +- Preserve user agency: a manual image, SF Symbol, clear/reset, or global opt-out always wins. +- Read only local files, make no network requests, and never modify the repository. +- Attempt automatic detection only for repository IDs newly added in the current operation; + startup, reload, and existing repositories never trigger a scan. + +### Non-goals + +- Remote owner avatars, code-host API calls, favicon downloads, telemetry, and generated + placeholder icons. +- Automatically choosing a repository color. Color changes sidebar, shelf, and canvas chrome + and have lower semantic confidence than a verified project icon. +- Foundation Model inference in the add path. It is advisory work, not a prerequisite for + adding or opening a project. +- Treating every `package.json`, `logo.svg`, or generated asset as product identity. + +## Findings and design constraints + +| Area | Observed implementation | Decision consequence | +| --- | --- | --- | +| Project classification | `supacode/Domain/WorktreeProjectKind.swift` uses a synchronous top-level listing and one ordered result. | Keep its editor behavior intact; introduce icon-specific evidence rather than overloading a single enum case. | +| Add lifecycle | `openRepositories` resolves roots, persists them, loads repositories, then focuses the first new repository. | Start detection only after `openRepositoriesFinished`; do not await it in the add effect. | +| Icon persistence | `RepositoryIconSource` supports SF Symbols and copied user images; `RepositoryIconAssetStore` imports a source URL atomically. | Reuse the store after validation, but do not let the detector bypass it. | +| Image validity | The asset store intentionally accepts arbitrary picker input and rendering later uses `NSImage(contentsOf:)`. | The detector needs stricter validation before copying: regular local file, contained path, supported raster/vector format, bounded bytes and pixels, and successful decode. | +| Existing model service | Prowl's `FoundationModelLLMService` is a one-call, 3-second plain-text service. | It is not a safe substitute for Glyphon's two-stage constrained-symbol pipeline. | +| Glyphon | `Sources/Glyphon` already separates retrieval from UI, bundles a 905 KB symbol index, and reports roughly 2.9 s/query for the tuned two-model-call flow. | Model suggestions may be useful only as cancellable, explicit settings recommendations. | + +## Design / Approach + +### 1. Deterministic, evidence-based asset detection + +Add a small, testable detector that returns an optional candidate and its evidence rather than +mutating settings. It may collect multiple project signals, while `WorktreeProjectKind` remains +the single-choice editor preference resolver. + +The first automatic release accepts only these high-confidence paths: + +1. An Apple asset catalog's `AppIcon.appiconset/Contents.json`, decoded as a manifest and + resolved to a contained, referenced image. Choose the largest valid referenced raster rather + than guessing from arbitrary catalog files. +2. An Android project proven by an Android manifest or Android Gradle plugin plus a standard + launcher raster in a `mipmap-*` directory. Adaptive XML and arbitrary drawable resources + are not enough by themselves. +3. Flutter only when `pubspec.yaml` and one of its platform application-icon layouts agree; + reuse the platform-specific evidence above. + +A root-level `logo.*`, `icon.*`, or web favicon is a lower-confidence follow-up, not an +initial automatic rule. Such names frequently refer to templates, documentation, or tooling. +They can be enabled only after a fixture corpus shows acceptable precision. + +Traversal is bounded and deterministic: scan a small fixed depth and entry budget, skip +`.git`, dependency/build directories (`node_modules`, `Pods`, `.build`, `DerivedData`, +`build`, `dist`, `target`, and `vendor`), never follow an escaping symlink, and stop at the +first candidate tier with a validated result. The detector validates image metadata before +copying to avoid a decompression bomb or an unusable file. + +### 2. Non-blocking lifecycle and persistence + +After a repository has appeared in state, launch at most a small bounded number of utility +priority detection tasks. Repository rendering and terminal focus proceed immediately. A +result commits only if all of these remain true at commit time: + +- the repository still exists and automatic detection remains enabled; +- the result belongs to the original normalized repository root and request generation; +- no manual icon or explicit user clear superseded the request; and +- no icon is already present. + +The appearance model needs durable origin/outcome information (manual, detected, suppressed) +in addition to the renderable icon. The current optional `RepositoryAppearance.icon` alone +cannot distinguish an intentional clear from an untouched repository when an asynchronous +result races with the settings UI. This information also prevents a removed-and-re-added +repository with retained appearance data from being scanned again unexpectedly. + +A validated candidate is copied through `RepositoryIconAssetStore` and persisted as a normal +`RepositoryIconSource.userImage`; Prowl never retains an absolute path into the repository. +Removal, replacement, and rendering continue through the existing paths. + +### 3. Settings and user control + +Add a global Appearance setting, **Detect project icons automatically**, enabled by default. +Its help text must state that detection is local, happens only when adding future repositories, +and never replaces a manual icon. Disabling it cancels pending work and leaves already detected +icons unchanged. Repository Settings should identify a detected icon and make **Clear Icon** +an explicit suppression, not an invitation for a pending detector to restore it. + +No model preference is needed for the first release because the model is never called +automatically. A user who does not request a recommendation supplies no README content to a +model. + +### 4. Separate, reviewable Foundation Model recommendation + +A later Repository Settings action may offer **Suggest an SF Symbol from README**. It is +explicitly user-invoked, cancellable, shows a progress state, presents one or more candidates, +and changes nothing until the user chooses **Use**. + +The input should be a bounded visible-text synopsis, not a raw "first N words" slice: +read a limited local README, discard front matter, badges, and fenced code, then cap to roughly +600 Unicode characters. A root package description can supplement it. This behaves predictably +for CJK text and matches Glyphon's current prompt budget. + +Glyphon's tuned recipe is the right technical reference: guided English keyword extraction, +semantic-root retrieval, a dynamically constrained candidate choice, greedy sampling, and a +validated plain-text fallback for schema guardrail refusals. It has important constraints: +the on-device model is unavailable on some Macs, its context window is finite, and the tuned +flow is materially too slow for automatic add. + +Do not import the current Glyphon package into Prowl yet. Although its `Glyphon` target has no +target-level third-party dependency, the package also declares RevenueCat and bundles its own +symbol dataset. First establish the Prowl recommendation API and its evaluation fixtures; then, +if the API stabilizes, extract a narrow `SFSymbolRecommender` package containing only symbol +metadata, retrieval, and the Foundation Models pipeline. Review Apple's SF Symbols/data +redistribution terms before publishing any bundled metadata as open source. + +## Alternatives & decisions + +| Option | Decision | Why | +| --- | --- | --- | +| GitHub owner avatar fallback | Rejected | Repeats identities across a user's repositories and needs network rendering/cache behavior. | +| Synchronous full-tree scan on add | Rejected | Large repositories and generated directories would make add latency unpredictable. | +| Reuse `WorktreeProjectKind` as the detector | Rejected | Its one primary kind is correct for editor choice, not sufficient evidence for icon provenance. | +| Auto-apply README/LLM result | Rejected | Model latency and semantic uncertainty violate the non-blocking, low-noise requirement. | +| Directly depend on Glyphon now | Deferred | Package boundary, binary/resource cost, API ownership, and SF Symbols redistribution review need a deliberate extraction. | + +## Verification plan + +- Unit-test marker and manifest fixtures: valid Apple/Android/Flutter candidates, malformed + manifests, missing files, path traversal, symlink escape, oversized/undecodable images, and + ambiguous hybrid repositories. +- Reducer tests: only newly added IDs schedule work; disabled settings, removal, manual set, + and manual clear prevent stale commits; no existing repository is revisited on reload. +- Performance test with a large synthetic tree: main-actor add path remains independent of the + scan; traversal obeys depth/entry limits and cancellation stops work promptly. +- Manual smoke test: add a fixture project, observe immediate opening followed by a valid icon; + disable the setting and confirm a second project stays text-only. +- Before extracting or publishing a recommender package, run Glyphon's held-out evaluation and + a Prowl-specific corpus of anonymized project descriptions; model-score deltas below its + documented run-to-run noise are not treated as regressions. + +## Amendments + +- 2026-07-25: Pre-implementation interview decisions are recorded in + [002-design-alignment.md](002-design-alignment.md), which supersedes conflicting provisional + choices above. A second round resolved the remaining open question (inline loading in the + picker sheet), the menu structure (Choose Symbol… plus Suggest an Icon…, both opening one + sheet with an in-sheet Suggest button), suggestion caching (session-scoped in-memory cache + with Regenerate), and delivery sequencing (extract the shared recommender package first, + then ship automatic detection and the suggestion flow together). diff --git a/docs-ai/051-repository-icon-detection/002-design-alignment.md b/docs-ai/051-repository-icon-detection/002-design-alignment.md new file mode 100644 index 00000000..41c2f7e2 --- /dev/null +++ b/docs-ai/051-repository-icon-detection/002-design-alignment.md @@ -0,0 +1,135 @@ +# 051.002 — Design Alignment + +## Context + +On 2026-07-25, the repository-icon plan was reviewed through a decision-by-decision +interview before implementation. This record supersedes the conflicting provisional choices in +`000-plan.md`; no production code has been changed. + +## Confirmed product decisions + +### Automatic local icon detection + +| Topic | Decision | +| --- | --- | +| Default | **Enabled** for new installations. | +| Scope | Only roots added after the setting is enabled: Git repositories and plain folders. Never scan Project Workspace containers or retroactively scan existing roots. | +| Feedback | Silent. A detected icon appears naturally; no toast, badge, or failure notice. | +| Model use | Never run Foundation Models during add. No validated local candidate means text-only identity. | +| Global disable | Applies to future additions only; it does not remove existing detected icons. | +| Clear Icon | Removes the icon and records an explicit suppression. There is no Re-detect command. Only removing and re-adding the root starts another automatic attempt. | +| Repository removal | Clear detected image assets and automatic/suppressed detection state, but preserve user-selected icon, color, and title. | + +### Classification and candidate resolution + +`WorktreeProjectKind` becomes the first-stage project classifier for both Automatic Open In and +icon probing. It must not itself assert icon validity: each kind delegates to a validating probe. + +| Order | Project kind / source | Resolution rule | +| --- | --- | --- | +| 1 | Flutter | Detect as a first-class kind; choose iOS AppIcon, then Android launcher. | +| 2 | React Native | Detect as a first-class kind; choose iOS AppIcon, then Android launcher. | +| 3 | Apple | Probe referenced `AppIcon.appiconset` images. | +| 4 | Android | Probe only complete raster launcher assets; adaptive icon XML is deferred. | +| 5 | Web | Probe source order: manifest icon, explicit HTML `rel=icon`, `public/favicon.*`, then root `logo.*` / `icon.*`. | + +The detector tries kinds in the table order and falls through when a higher-priority kind has no +valid candidate. For multiple valid candidates in the same platform, choose the path nearest to +the repository root; break ties by normalized relative-path lexicographic order. Within one +Apple icon catalog, choose the largest valid referenced raster. + +Flutter requires its own positive project signal; React Native requires a package dependency and +native layout. Static Web folders without `package.json` are recognized only when root +`index.html` is accompanied by a qualifying Web icon asset. + +Detected SVGs preserve their intrinsic colors. This is a separate rendering mode from existing +user-selected SVGs, which continue to follow the repository tint setting. + +### Automatic Open In additions + +The new classifications also improve Automatic Open In: + +| Kind | Preferred actions before generic fallback | +| --- | --- | +| Flutter | Android Studio → IntelliJ → VS Code family | +| React Native | VS Code family → WebStorm → Android Studio | + +### Explicit SF Symbol suggestions + +The Repository Icon menu gains **Suggest an Icon…** and keeps it available for empty, detected, +and manually selected icons alike. It opens Choose Symbol and does not persist anything until the +user selects a result. + +| Topic | Decision | +| --- | --- | +| Results | Show Glyphon's best choice, four alternates, and reason in a `Suggested for this repository` section. | +| Input order | Clean visible README synopsis → root manifest description → repository display name. Do not concatenate all sources blindly. | +| Disclosure | The result view states the actual source, for example `Based on README`. | +| Model failure/unavailability | Show retrieval-only candidates labelled `Keyword suggestions`; never imply they are model recommendations. | +| Availability | Suggest an Icon remains available even when an icon already exists; choosing a result is an explicit replacement. | +| Automatic use | Never call the model during add and never auto-apply a model result. | + +## Shared package prerequisite — met (2026-07-25) + +The extraction completed on the Glyphon side. Prowl consumes: + +| | | +| --- | --- | +| Package | [github.com/onevcat/GlyphonKit](https://github.com/onevcat/GlyphonKit), public, MIT | +| Dependency | `.package(url: "https://github.com/onevcat/GlyphonKit.git", from: "0.1.0")`, product `GlyphonKit` | +| Module | `import GlyphonKit`; the facade type keeps the name `Glyphon` | +| API used by 051 | `Glyphon()`, `recommend(from:)` → `SymbolRecommendation` (`symbol`, 4 `alternates`, `reason`, `usedAI`), `search(_:limit:)`, `AIAvailability.current` | +| Fallback contract | `usedAI == false` marks a retrieval-only result → label as `Keyword suggestions` | +| Cancellation | `recommend` throws `CancellationError` within ~0.1 s of Task cancellation, verified on-device | +| Fidelity | Pipeline moved verbatim; post-move eval train 0.921 / test 0.844 meanPickScore, 100% acceptable, within run-noise band | +| Notes | Input capped internally at 600 characters (matches the README-synopsis budget); ~3 s/query on an M-series Mac; extraction record in Glyphon repo `docs/ai/003-core-package-extraction/outcome.md` | + +## Second-round decisions (2026-07-25, interview continued) + +A follow-up session resumed the interview and closed the remaining branches. + +### Model-wait presentation (previously the open question) + +Selecting **Suggest an Icon…** opens Choose Symbol immediately. The picker sheet gains a +`Suggested for this repository` section that shows an inline loading state while the model +runs; the sheet stays closable and ordinary symbol selection remains available throughout. +Results replace the loading state in place. Neither a menu-side wait nor a separate progress +dialog is used. + +### Menu structure + +The Repository Icon menu keeps **Choose Symbol…** and adds **Suggest an Icon…**; both open the +same picker sheet. Only Suggest an Icon… starts generation automatically. When the sheet is +opened via Choose Symbol…, the Suggestions section is idle and offers an explicit **Suggest** +button. A model call therefore always corresponds to an explicit user action, and the feature +is discoverable from both the menu and the sheet. + +### Suggestion result lifecycle + +| Topic | Decision | +| --- | --- | +| Closing the sheet mid-generation | Cancels the in-flight model call. | +| Successful results | Cached in memory per repository for the app session; never persisted to disk. | +| Reopening the sheet | Shows cached results immediately with their source label; the Suggest button becomes **Regenerate**. | +| App relaunch | Cache starts empty; suggestions regenerate on demand. | + +### Delivery sequencing + +051 ships as a single delivery after the shared package exists: extract Glyphon's pipeline +into the shared recommender package first, then implement automatic detection and the +suggestion flow together in Prowl. A split that shipped model-free automatic detection first +was proposed and rejected: even though detection has no model dependency, one coherent +delivery is preferred over touching Repository Settings twice. 051 was therefore blocked on +the package extraction, which proceeded as separate Glyphon-side work using the prepared +handoff prompt. The prerequisite is now met — see the section below for the published +coordinates. + +## Implementation consequences + +- `RepositoryAppearance` needs distinct manual, detected, and suppressed state so delayed results + cannot overwrite a clear or manual choice. +- `RepositoryIconSource` / rendering needs a non-template detected-SVG representation without + changing existing user SVG tint behavior. +- Removal must selectively clean automatic artifacts/state while preserving manual appearance. +- The detector must remain bounded, cancellable, path-contained, and image-validated before it + copies a candidate through `RepositoryIconAssetStore`. diff --git a/docs-ai/README.md b/docs-ai/README.md index 29d18c2d..b5cceedd 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -102,3 +102,4 @@ agent-facing manual for that). | 048 | [agent-runtime-adapters](048-agent-runtime-adapters/000-plan.md) | 2026-07-18 | Protocol-backed agent session resume and configurable launch invocations | | 049 | [agents-toolbar-entry](049-agents-toolbar-entry/000-plan.md) | 2026-07-20 | Agents status capsule + staged handoff HUD toolbar entry | | 050 | [sidebar-expand-active-and-worktree-tab-badges](050-sidebar-expand-active-and-worktree-tab-badges/000-plan.md) | 2026-07-25 | Sidebar Expand Active third state + per-worktree tab count badges | +| 051 | [repository-icon-detection](051-repository-icon-detection/000-plan.md) | 2026-07-25 | High-confidence local repository icon detection on add; deferred, reviewable Foundation Model recommendations | -- 2.51.2 From 19fd299636343f299dc9a6f8caff0d53cfb87044 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 25 Jul 2026 20:51:02 +0900 Subject: [PATCH 2/7] Add repository icon detection and GlyphonKit-backed icon suggestions Newly added repositories are scanned once, in the background, for a high-confidence product icon (Apple asset catalog, Android launcher, Flutter/RN shells, web manifest/favicon/logo); a validated hit becomes the repo icon silently and never overrides a manual choice or an explicit clear. Repository Settings gains Suggest an Icon, an on-device SF Symbol recommendation flow backed by GlyphonKit v0.1.0 with session caching, honest source labels, and cooperative cancellation. Flutter and React Native become first-class project kinds for Automatic Open In. Implements docs-ai 051 (issue #525). Claude-Session: https://claude.ai/code/session_01FzjRrTgza7QTwmaLY258rv --- .../001-action.md | 139 ++++++ docs/components/repositories-and-worktrees.md | 27 +- docs/components/settings.md | 2 +- docs/reference/settings-fields.md | 1 + supacode.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Repositories/RepositoryIconDetector.swift | 333 +++++++++++++ .../RepositoryIconDetectorClient.swift | 34 ++ .../RepositoryIconDetectorScanner.swift | 177 +++++++ .../RepositorySymbolSuggestionClient.swift | 102 ++++ supacode/Domain/RepositoryAppearance.swift | 44 +- supacode/Domain/RepositoryIconSource.swift | 31 +- .../Domain/RepositorySuggestionInput.swift | 148 ++++++ .../Domain/RepositorySymbolSuggestions.swift | 39 ++ supacode/Domain/WorktreeProjectKind.swift | 63 +++ .../Features/App/Reducer/AppFeature.swift | 5 + .../RepositoriesFeature+IconDetection.swift | 120 +++++ ...sitoriesFeature+RepositoryManagement.swift | 49 +- .../Reducer/RepositoriesFeature.swift | 10 + .../Views/RepositoryIconImage.swift | 2 +- .../Reducer/RepositorySettingsFeature.swift | 131 ++++- .../RepositoryAppearancePickerView.swift | 35 +- .../RepositorySymbolSuggestionsSection.swift | 111 +++++ .../Settings/Models/GlobalSettings.swift | 6 + .../Settings/Reducer/SettingsFeature.swift | 3 + .../Views/AppearanceSettingsView.swift | 13 + .../TabBar/Views/TabIconPickerView.swift | 13 + ...epositoriesFeatureIconDetectionTests.swift | 451 ++++++++++++++++++ supacodeTests/RepositoryAppearanceTests.swift | 35 ++ .../RepositoryIconDetectorTests.swift | 369 ++++++++++++++ supacodeTests/RepositoryIconSourceTests.swift | 33 ++ .../RepositorySettingsAppearanceTests.swift | 67 ++- .../RepositorySettingsSuggestionsTests.swift | 248 ++++++++++ .../RepositorySuggestionInputTests.swift | 138 ++++++ supacodeTests/TemporaryProjectDirectory.swift | 9 + supacodeTests/WorktreeProjectKindTests.swift | 78 +++ 36 files changed, 3054 insertions(+), 40 deletions(-) create mode 100644 docs-ai/051-repository-icon-detection/001-action.md create mode 100644 supacode/Clients/Repositories/RepositoryIconDetector.swift create mode 100644 supacode/Clients/Repositories/RepositoryIconDetectorClient.swift create mode 100644 supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift create mode 100644 supacode/Clients/Repositories/RepositorySymbolSuggestionClient.swift create mode 100644 supacode/Domain/RepositorySuggestionInput.swift create mode 100644 supacode/Domain/RepositorySymbolSuggestions.swift create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift create mode 100644 supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift create mode 100644 supacodeTests/RepositoriesFeatureIconDetectionTests.swift create mode 100644 supacodeTests/RepositoryIconDetectorTests.swift create mode 100644 supacodeTests/RepositorySettingsSuggestionsTests.swift create mode 100644 supacodeTests/RepositorySuggestionInputTests.swift diff --git a/docs-ai/051-repository-icon-detection/001-action.md b/docs-ai/051-repository-icon-detection/001-action.md new file mode 100644 index 00000000..9646d5dc --- /dev/null +++ b/docs-ai/051-repository-icon-detection/001-action.md @@ -0,0 +1,139 @@ +# 051 — Repository Icon Detection: Action Log + +| | | +| --- | --- | +| **Status** | Implemented (single delivery: automatic detection + Suggest an Icon) | +| **Anchor date** | 2026-07-25 | +| **Primary PRs** | — (filled on merge) | +| **Depends on** | [GlyphonKit](https://github.com/onevcat/GlyphonKit) `v0.1.0` | + +## What shipped + +Both halves of the plan landed together, per the delivery-sequencing +decision in [002-design-alignment.md](002-design-alignment.md): + +1. **Automatic local icon detection** — newly added repositories and + plain folders are scanned once, in the background, for a + high-confidence product icon; a validated hit silently becomes the + repository icon. +2. **Suggest an Icon…** — an explicit, cancellable, on-device SF Symbol + recommendation flow in Repository Settings, backed by GlyphonKit. + +## Implementation map + +### Detection engine + +- `supacode/Clients/Repositories/RepositoryIconDetector.swift` — probe + order Flutter → React Native → Apple → Android → Web, each gated on + its own positive project signal with fall-through. Apple resolves the + largest valid raster referenced by the nearest + `AppIcon.appiconset/Contents.json`; Android takes the highest-density + `mipmap-*` launcher raster from well-known module layouts (adaptive + XML is skipped); hybrid kinds probe `ios/` then `android/`; Web walks + manifest icons → HTML `rel=icon` → `public/`/root favicons → root + `logo.*`/`icon.*`, rejecting remote/data/query references. +- `supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift` — + single choke point for traversal and validation: BFS bounded by + depth, a 5 000-entry budget, and a dependency-directory skip list; + never follows symlinks; validates candidates (contained in the repo + after symlink resolution, regular file, ≤ 5 MB, allowed format, + ImageIO metadata probe within 16–4096 px, SVG sniff + real `NSImage` + decode) before anything is imported. +- `supacode/Clients/Repositories/RepositoryIconDetectorClient.swift` — + dependency wrapper; tests inject fixtures, `testValue` finds nothing. + +### Lifecycle (RepositoriesFeature) + +- `RepositoriesFeature+IconDetection.swift` — spawn/commit/cleanup: + - Spawn in `openRepositoriesFinished` for repositories newly added in + the current operation only (`wasAlreadyLoaded`, not restoring), + capped at 8 per add, utility priority, per-repo cancel ID plus one + umbrella ID. Workspace containers, suppressed repos, and repos with + any icon are skipped. A scan that finds nothing ends silently. + - Commit re-checks every guard (repo exists, setting enabled, icon + still nil, not suppressed) and otherwise deletes the imported + asset; success writes `RepositoryIconSource.detectedImage` into + `@Shared(.repositoryAppearances)`. + - Removal (`repositoryRemoved` / `removeFailedRepository`) cancels a + pending scan, deletes the detected asset, clears the suppression + flag, and preserves user icon/color — a re-added repo scans fresh. + - Global toggle-off routes `cancelPendingIconDetections` from + `AppFeature`'s `settingsChanged` through the umbrella cancel ID. + +### Model & rendering changes + +- `RepositoryIconSource.detectedImage(filename:)`, marker `@detected:`. + Never tintable — a detected SVG keeps its intrinsic colors, unlike + user-picked SVGs. `storedImageFilename` unifies file-backed cleanup. +- `RepositoryAppearance.iconDetectionSuppressed` with custom Codable + (absent key defaults to false; false is omitted on encode). `isEmpty` + counts the flag so suppression-only entries survive pruning. +- Clear Icon and an icon-removing Reset record suppression in + `RepositorySettingsFeature`, so an in-flight scan can never resurrect + a cleared icon. +- `GlobalSettings.detectRepositoryIconsAutomatically` (default true) + + toggle in Appearance settings ("Repository Icons" section). + +### Suggest an Icon (GlyphonKit) + +- Dependency: GlyphonKit `upToNextMajor(from: 0.1.0)` added to + `supacode.xcodeproj` (raw pbxproj, YiTong as the template). +- `supacode/Domain/RepositorySuggestionInput.swift` — input order + README synopsis → manifest description (`package.json`, + `pubspec.yaml`, `Cargo.toml`) → display name; the synopsis strips + front matter, badges, fenced code, HTML, and link targets before a + 600-character cap (GlyphonKit's own prompt budget). +- `supacode/Clients/Repositories/RepositorySymbolSuggestionClient.swift` + — actor-backed session cache per repository plus lazy one-time + database load; `generate` maps `SymbolRecommendation` (symbol, 4 + alternates, reason, `usedAI`) into `RepositorySymbolSuggestions`. +- `RepositorySettingsFeature` — `SymbolSuggestionsPhase` + (idle/loading/loaded/failed); Choose Symbol… opens the sheet and only + surfaces a cached run; Suggest an Icon… opens it and starts (or + resolves) a run; Regenerate bypasses the cache; closing the sheet + cancels in-flight generation (GlyphonKit throws `CancellationError` + within ~0.1 s) and resets a transient loading state. +- UI: `RepositorySymbolSuggestionsSection` renders the + "Suggested for this repository" block inside the shared + `TabIconPickerView` via a new optional injection slot; results show + the primary + 4 alternates, the reason, and a source label — + retrieval-only fallbacks are labeled `Keyword suggestions`. Tapping a + suggestion fills the symbol field; nothing persists until Done. + +### Open In additions + +`WorktreeProjectKind` gains first-class `flutter` / `reactNative` +detection (manifest-positive signals, checked before Apple/Android) and +their documented editor preferences (Flutter: Android Studio → IntelliJ +→ VS Code family; RN: VS Code family → WebStorm → Android Studio). + +## Verification + +- `make check` clean; `make build-app` and the full `make test` suite + green (see PR). +- New suites: `RepositoryIconDetectorTests` (fixture-driven probes, + validation, symlink escape, skip list), + `RepositoriesFeatureIconDetectionTests` (spawn eligibility, commit + guards, removal cleanup), `RepositorySettingsSuggestionsTests` + (suggest/cache/regenerate/cancel), `RepositorySuggestionInputTests` + (markdown cleaning, source order), plus extended + `WorktreeProjectKindTests`, `RepositoryIconSourceTests`, + `RepositoryAppearanceTests`, and updated + `RepositorySettingsAppearanceTests` for suppression semantics. +- Real model output is not exercised in CI: GlyphonKit's pipeline is + eval-guarded in its own repository (post-move train 0.921 / test + 0.844 meanPickScore), and Prowl's reducer tests treat the client as a + fixture boundary. + +## Deviations from the plan + +- No pending-scan bookkeeping in `State`: cancellation uses a shared + umbrella effect ID plus per-repo IDs instead of a tracked set, which + keeps `openRepositoriesFinished` behavior invisible to unrelated + reducer tests. +- "Disabling cancels pending work" is implemented as umbrella + cancellation from `AppFeature` *and* a commit-time guard; a scan + finishing in the gap discards its result and deletes the asset. +- Suggest an Icon while results already exist re-presents the existing + run instead of regenerating (Regenerate is the explicit fresh-run + path) — matches the session-cache decision. diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 99f160c5..b0609e3f 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -155,7 +155,11 @@ it from Prowl (closing its open terminals); it does **not** delete files on disk `⌘O` opens the worktree with the selected open action. When the action is **Automatic** (the default), Prowl inspects the worktree's top-level files and -prefers an app matching the project type: `.xcodeproj`/`.xcworkspace`/ +prefers an app matching the project type: Flutter (`pubspec.yaml` with a +`flutter:` key) → Android Studio (then IntelliJ, then the VS Code family), +React Native (`package.json` depending on `react-native` plus an `ios/` or +`android/` folder) → VS Code family (then WebStorm, then Android Studio), +`.xcodeproj`/`.xcworkspace`/ `Package.swift`/`Project.swift` → Xcode, Gradle files → Android Studio (then IntelliJ IDEA, then IDEA EAP), `*.sln`/`*.csproj` → Rider, `pom.xml` → IntelliJ IDEA (then IDEA EAP), `go.mod` → GoLand, `Cargo.toml` → RustRover, @@ -189,6 +193,27 @@ a custom color). The color tints the icon, the name, the Shelf spine (if `windowTintMode = repositoryColor`). You can also set a **custom display title** (`customTitle`) that overrides the folder name. +**Automatic icon detection** (`detectRepositoryIconsAutomatically`, default on): +when a repository or folder is newly added, Prowl scans it locally in the +background for a high-confidence product icon — an Apple `AppIcon.appiconset` +raster, an Android launcher raster, the iOS/Android assets of a Flutter or +React Native project, or a web manifest icon / `rel=icon` favicon / root +logo for web projects — and silently sets it as the repo icon. Detection +never runs for existing repositories, workspaces, or repos that already have +an icon, and it never replaces a manual choice. **Clear Icon** removes a +detected icon and suppresses re-detection; only removing and re-adding the +repository triggers a fresh scan. Detected icons keep their original colors +(they are never tinted, unlike user-picked SVGs/symbols). + +**Suggest an Icon…** (Repository Icon menu): generates SF Symbol suggestions +for the repository on-device from its README (falling back to the package +manifest description, then the repo name). The picker sheet opens immediately +with an inline loading state; results show the best pick plus four +alternates, a reasoning line, and the input source. When Apple Intelligence +is unavailable the results are labeled **Keyword suggestions**. Nothing is +applied until you pick a symbol and confirm. Results are cached in memory for +the session; **Regenerate** runs a fresh pass. + ## Lifecycle states (what the row can show) - **Pending** — worktree is being created (grey row, live stage text). diff --git a/docs/components/settings.md b/docs/components/settings.md index 7fd47010..9fdfc47f 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -16,7 +16,7 @@ window is a sidebar of tabs plus a detail pane. | Tab | Controls | |-----|----------| -| **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & terminal titles. | +| **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, automatic repository icon detection, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & terminal titles. | | **Notifications** | In-app alerts, notification sound picker (Never / system sounds / Prowl Classic), macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, automatic local-branch cleanup, merged-worktree action, archived auto-delete period. | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index fd9fbc4b..de8ae329 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -67,6 +67,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `shelfSpineTintFollowsRepositoryColor` | Bool | `true` | Tint shelf spines by repo color. | | `externalDiffToolID` | String | `built-in` | Tool used by diff badges and Show Diff: `built-in`, `hunk`, `filemerge`, `kaleidoscope`, or `custom`. | | `externalDiffCustomCommand` | String | `""` | Command template for `externalDiffToolID = custom`; supports `{leftPath}`, `{rightPath}`, `{worktreePath}`, `{repoPath}`, and `{branch}`. | +| `detectRepositoryIconsAutomatically` | Bool | `true` | Scan newly added repositories locally for a high-confidence project icon (app icon, launcher, favicon/logo) and use it as the repo icon. Applies to future additions only; never replaces a manual icon. | ## Per-repository settings (`RepositorySettings`) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 3ca0080e..8115cb94 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 886A649F771240E8A5AC792D /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 8D787DCF45744283A83F226F /* Dependencies */; }; C8B33B9AFE69E738ABFA08BE /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 497B582F42253CBC8FBF101F /* ComposableArchitecture */; }; D1BB05892F5ACB5900553512 /* YiTong in Frameworks */ = {isa = PBXBuildFile; productRef = D1BB05882F5ACB5900553512 /* YiTong */; }; + 66055880DA0BAF0D05D67D02 /* GlyphonKit in Frameworks */ = {isa = PBXBuildFile; productRef = 0C10F7612452A2EF85A71541 /* GlyphonKit */; }; D63D00F72F2A5EEE00018D05 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = D63D00F62F2A5EEE00018D05 /* DependenciesTestSupport */; }; D64162B02F23CAF100260CA3 /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = D64162AD2F23CAF100260CA3 /* ghostty */; }; D64162B12F23CAF100260CA3 /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = D64162AE2F23CAF100260CA3 /* terminfo */; }; @@ -78,6 +79,7 @@ buildActionMask = 2147483647; files = ( D1BB05892F5ACB5900553512 /* YiTong in Frameworks */, + 66055880DA0BAF0D05D67D02 /* GlyphonKit in Frameworks */, C8B33B9AFE69E738ABFA08BE /* ComposableArchitecture in Frameworks */, 886A649F771240E8A5AC792D /* Dependencies in Frameworks */, D6A1CB312F1F4ABF004FDABD /* GhosttyKit.xcframework in Frameworks */, @@ -162,6 +164,7 @@ 8B2985744DB94333AE1B5878 /* Sentry */, D6D054272F2E3EBF00D70ED5 /* PostHog */, D1BB05882F5ACB5900553512 /* YiTong */, + 0C10F7612452A2EF85A71541 /* GlyphonKit */, ); productName = supacode; productReference = D69CE04A2F1F378200584C57 /* Prowl.app */; @@ -227,6 +230,7 @@ 899A0B6B6B42424B92CD20C2 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, D6D054262F2E3EB300D70ED5 /* XCRemoteSwiftPackageReference "posthog-ios" */, D1BB05872F5ACB5900553512 /* XCRemoteSwiftPackageReference "YiTong" */, + AA918EA35EA8913669138FE5 /* XCRemoteSwiftPackageReference "GlyphonKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = D69CE04B2F1F378200584C57 /* Products */; @@ -703,6 +707,14 @@ minimumVersion = 0.2.0; }; }; + AA918EA35EA8913669138FE5 /* XCRemoteSwiftPackageReference "GlyphonKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/GlyphonKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; D6D054262F2E3EB300D70ED5 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PostHog/posthog-ios.git"; @@ -739,6 +751,11 @@ package = D1BB05872F5ACB5900553512 /* XCRemoteSwiftPackageReference "YiTong" */; productName = YiTong; }; + 0C10F7612452A2EF85A71541 /* GlyphonKit */ = { + isa = XCSwiftPackageProductDependency; + package = AA918EA35EA8913669138FE5 /* XCRemoteSwiftPackageReference "GlyphonKit" */; + productName = GlyphonKit; + }; D63D00F62F2A5EEE00018D05 /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = 46ECCA2394D141A89BE74516 /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 89359eb8..57082ab8 100644 --- a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bff63941030a723fd0d04591f427af4a080c1f826220929fca37799cf784ab71", + "originHash" : "e72afa9e5f10f1505a3d4fb929733c3183a228458b9840524d15027f7afd88ff", "pins" : [ { "identity" : "combine-schedulers", @@ -10,6 +10,15 @@ "version" : "1.1.0" } }, + { + "identity" : "glyphonkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/GlyphonKit.git", + "state" : { + "revision" : "9a9e44a5e137fe45db7ff548c2fd64c1ae831815", + "version" : "0.1.0" + } + }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", diff --git a/supacode/Clients/Repositories/RepositoryIconDetector.swift b/supacode/Clients/Repositories/RepositoryIconDetector.swift new file mode 100644 index 00000000..7bd48bb6 --- /dev/null +++ b/supacode/Clients/Repositories/RepositoryIconDetector.swift @@ -0,0 +1,333 @@ +import AppKit +import Foundation +import ImageIO + +/// A validated icon file found inside a repository, plus the project +/// evidence that made it trustworthy. The detector never mutates the +/// repository or settings — committing a candidate is the reducer's job. +nonisolated struct RepositoryIconCandidate: Equatable, Sendable { + enum Evidence: Equatable, Sendable { + case appleAssetCatalog + case androidLauncher + case webAsset + } + + let imageURL: URL + let evidence: Evidence +} + +/// Evidence-based local probe for a repository's own product icon. +/// +/// Kinds are tried in a fixed order — Flutter, React Native, Apple, +/// Android, Web — and each kind requires its own positive project +/// signal before probing, falling through when it yields no valid +/// candidate. Traversal is bounded (depth, entry budget, skip list, +/// no symlinks) so the scan stays cheap even in huge repositories, +/// and every candidate is validated (contained in the repo, regular +/// file, bounded size, decodable) before being returned. +nonisolated enum RepositoryIconDetector { + + static func detect(at rootURL: URL, fileManager: FileManager = .default) -> RepositoryIconCandidate? { + let scanner = Scanner(rootURL: rootURL, fileManager: fileManager) + let names = scanner.entryNames(of: rootURL) + guard !names.isEmpty else { return nil } + + if names.contains("pubspec.yaml"), WorktreeProjectKind.pubspecDeclaresFlutter(in: rootURL), + let candidate = probeHybridShells(scanner: scanner) + { + return candidate + } + if names.contains("package.json"), + names.contains("ios") || names.contains("android"), + WorktreeProjectKind.packageJSONDependsOnReactNative(in: rootURL), + let candidate = probeHybridShells(scanner: scanner) + { + return candidate + } + if names.contains(where: { + $0.hasSuffix(".xcodeproj") || $0.hasSuffix(".xcworkspace") + }) || names.contains("package.swift") || names.contains("project.swift"), + let imageURL = probeAppleCatalog(under: scanner.rootURL, scanner: scanner) + { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleAssetCatalog) + } + if names.contains("settings.gradle") || names.contains("settings.gradle.kts") + || names.contains("build.gradle") || names.contains("build.gradle.kts") + || names.contains("gradlew"), + let imageURL = probeAndroidLauncher(under: scanner.rootURL, scanner: scanner) + { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .androidLauncher) + } + if names.contains("package.json") || names.contains("index.html"), + let imageURL = probeWebAsset(scanner: scanner) + { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .webAsset) + } + return nil + } + + // MARK: - Hybrid (Flutter / React Native) + + /// Both hybrid kinds resolve the same way: the iOS shell's asset + /// catalog first, then the Android shell's launcher raster. + private static func probeHybridShells(scanner: Scanner) -> RepositoryIconCandidate? { + let iosShell = scanner.rootURL.appending(path: "ios", directoryHint: .isDirectory) + if let imageURL = probeAppleCatalog(under: iosShell, scanner: scanner) { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleAssetCatalog) + } + let androidShell = scanner.rootURL.appending(path: "android", directoryHint: .isDirectory) + if let imageURL = probeAndroidLauncher(under: androidShell, scanner: scanner) { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .androidLauncher) + } + return nil + } + + // MARK: - Apple + + /// Finds `AppIcon.appiconset` catalogs under `directory` (nearest to + /// the root first, then lexicographic) and returns the largest valid + /// raster referenced by the first catalog that yields one. + private static func probeAppleCatalog(under directory: URL, scanner: Scanner) -> URL? { + let catalogs = scanner.findDirectories(named: "appicon.appiconset", under: directory, maxDepth: 6) + for catalog in catalogs { + if let imageURL = largestValidImage(inAppIconSet: catalog, scanner: scanner) { + return imageURL + } + } + return nil + } + + private static func largestValidImage(inAppIconSet catalog: URL, scanner: Scanner) -> URL? { + let manifestURL = catalog.appending(path: "Contents.json", directoryHint: .notDirectory) + guard let data = scanner.boundedContents(of: manifestURL, limit: 1024 * 1024), + let manifest = try? JSONDecoder().decode(AppIconSetManifest.self, from: data) + else { + return nil + } + let ranked = + manifest.images + .compactMap { entry -> (filename: String, pixels: Int)? in + guard let filename = entry.filename, !filename.isEmpty else { return nil } + return (filename, pixelArea(size: entry.size, scale: entry.scale)) + } + .sorted { lhs, rhs in + lhs.pixels == rhs.pixels ? lhs.filename < rhs.filename : lhs.pixels > rhs.pixels + } + for entry in ranked { + let imageURL = catalog.appending(path: entry.filename, directoryHint: .notDirectory) + if scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } + return nil + } + + /// `size` is `"WxH"`, `scale` is `"Nx"`; a single-size catalog entry + /// may omit the scale. Unparseable entries rank last, not out. + private static func pixelArea(size: String?, scale: String?) -> Int { + guard let size else { return 0 } + let dimensions = size.lowercased().split(separator: "x").compactMap { Double($0) } + guard dimensions.count == 2 else { return 0 } + let scaleFactor = scale.flatMap { Double($0.lowercased().replacing("x", with: "")) } ?? 1 + return Int(dimensions[0] * scaleFactor * dimensions[1] * scaleFactor) + } + + // MARK: - Android + + private static let androidDensityDirectories = [ + "mipmap-xxxhdpi", "mipmap-xxhdpi", "mipmap-xhdpi", "mipmap-hdpi", "mipmap-mdpi", + ] + private static let androidLauncherFilenames = [ + "ic_launcher.png", "ic_launcher.webp", "ic_launcher_round.png", "ic_launcher_round.webp", + ] + + /// Probes well-known module layouts (`/src/main/res` and + /// `src/main/res`) for a standard launcher raster, highest density + /// first. Adaptive-icon XML has no raster to import and is skipped. + private static func probeAndroidLauncher(under directory: URL, scanner: Scanner) -> URL? { + var resDirectories: [URL] = [ + directory.appending(path: "src/main/res", directoryHint: .isDirectory) + ] + for module in scanner.subdirectoryNames(of: directory) { + resDirectories.append( + directory.appending(path: "\(module)/src/main/res", directoryHint: .isDirectory) + ) + } + for resDirectory in resDirectories where scanner.directoryExists(resDirectory) { + for density in androidDensityDirectories { + for filename in androidLauncherFilenames { + let imageURL = + resDirectory + .appending(path: density, directoryHint: .isDirectory) + .appending(path: filename, directoryHint: .notDirectory) + if scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } + } + } + return nil + } + + // MARK: - Web + + /// Source order: web app manifest, explicit HTML `rel=icon`, then + /// conventional favicon/logo files. A static folder (no + /// `package.json`) qualifies only via a root `index.html`. + private static func probeWebAsset(scanner: Scanner) -> URL? { + let root = scanner.rootURL + let publicDirectory = root.appending(path: "public", directoryHint: .isDirectory) + let searchDirectories = [root, publicDirectory] + + for directory in searchDirectories { + for name in ["manifest.webmanifest", "site.webmanifest", "manifest.json"] { + let manifestURL = directory.appending(path: name, directoryHint: .notDirectory) + if let imageURL = largestValidManifestIcon(at: manifestURL, baseDirectory: directory, scanner: scanner) { + return imageURL + } + } + } + for directory in searchDirectories { + let htmlURL = directory.appending(path: "index.html", directoryHint: .notDirectory) + if let imageURL = linkedIcon(inHTMLAt: htmlURL, baseDirectory: directory, scanner: scanner) { + return imageURL + } + } + for directory in [publicDirectory, root] { + for name in ["favicon.svg", "favicon.png", "favicon.ico"] { + let imageURL = directory.appending(path: name, directoryHint: .notDirectory) + if scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } + } + for name in ["logo.svg", "logo.png", "icon.svg", "icon.png"] { + let imageURL = root.appending(path: name, directoryHint: .notDirectory) + if scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } + return nil + } + + private static func largestValidManifestIcon( + at manifestURL: URL, + baseDirectory: URL, + scanner: Scanner + ) -> URL? { + guard let data = scanner.boundedContents(of: manifestURL, limit: 512 * 1024), + let manifest = try? JSONDecoder().decode(WebManifest.self, from: data), + let icons = manifest.icons + else { + return nil + } + let ranked = + icons + .compactMap { icon -> (src: String, pixels: Int)? in + guard let src = icon.src, !src.isEmpty else { return nil } + return (src, declaredPixelArea(sizes: icon.sizes, source: src)) + } + .sorted { lhs, rhs in + lhs.pixels == rhs.pixels ? lhs.src < rhs.src : lhs.pixels > rhs.pixels + } + for icon in ranked { + let references = resolveWebReferences(icon.src, baseDirectory: baseDirectory, scanner: scanner) + for imageURL in references where scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } + return nil + } + + /// `sizes` is space-separated `"WxH"` tokens or `"any"` (scalable — + /// ranked like a large raster so SVG icons win over small PNGs). + private static func declaredPixelArea(sizes: String?, source: String) -> Int { + let scalableRank = 512 * 512 + guard let sizes, !sizes.isEmpty else { + return source.lowercased().hasSuffix(".svg") ? scalableRank : 0 + } + var best = 0 + for token in sizes.lowercased().split(separator: " ") { + if token == "any" { + best = max(best, scalableRank) + continue + } + let dimensions = token.split(separator: "x").compactMap { Int($0) } + if dimensions.count == 2 { + best = max(best, dimensions[0] * dimensions[1]) + } + } + return best + } + + /// Extracts the first `` (or `shortcut icon`) + /// href from a bounded read of the HTML. A regex scan is enough — + /// this is an existence probe, not a browser. + private static func linkedIcon(inHTMLAt htmlURL: URL, baseDirectory: URL, scanner: Scanner) -> URL? { + guard let data = scanner.boundedContents(of: htmlURL, limit: 256 * 1024), + let html = String(data: data, encoding: .utf8) + else { + return nil + } + let linkTag = /]*>/.ignoresCase() + let relAttribute = /rel\s*=\s*["'](?:shortcut\s+)?icon["']/.ignoresCase() + let hrefAttribute = /href\s*=\s*["']([^"']+)["']/.ignoresCase() + for match in html.matches(of: linkTag) { + let tag = String(match.output) + guard tag.contains(relAttribute), + let href = tag.firstMatch(of: hrefAttribute).map({ String($0.output.1) }) + else { + continue + } + let references = resolveWebReferences(href, baseDirectory: baseDirectory, scanner: scanner) + for imageURL in references where scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } + return nil + } + + /// Maps a manifest/HTML reference onto candidate local files. Remote + /// URLs, data URIs, and query-string tricks are rejected outright; + /// root-relative paths are tried against the repo root and `public/`. + private static func resolveWebReferences( + _ reference: String, + baseDirectory: URL, + scanner: Scanner + ) -> [URL] { + let trimmed = reference.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains(":"), !trimmed.contains("?"), !trimmed.contains("#") + else { + return [] + } + if trimmed.hasPrefix("/") { + let relative = String(trimmed.dropFirst()) + return [ + scanner.rootURL.appending(path: relative, directoryHint: .notDirectory), + scanner.rootURL.appending(path: "public/\(relative)", directoryHint: .notDirectory), + ] + } + return [baseDirectory.appending(path: trimmed, directoryHint: .notDirectory)] + } +} + +/// `AppIcon.appiconset/Contents.json` shape — decoded fields only. +nonisolated private struct AppIconSetManifest: Decodable { + struct Entry: Decodable { + let filename: String? + let size: String? + let scale: String? + } + + let images: [Entry] +} + +/// Web app manifest shape — decoded fields only. +nonisolated private struct WebManifest: Decodable { + struct Icon: Decodable { + let src: String? + let sizes: String? + } + + let icons: [Icon]? +} diff --git a/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift b/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift new file mode 100644 index 00000000..c6d18283 --- /dev/null +++ b/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift @@ -0,0 +1,34 @@ +import Dependencies +import Foundation + +/// Dependency wrapper around `RepositoryIconDetector` so reducer tests +/// can substitute fixture candidates without touching the file system. +nonisolated struct RepositoryIconDetectorClient: Sendable { + var detect: @Sendable (_ rootURL: URL) async -> RepositoryIconCandidate? +} + +nonisolated enum RepositoryIconDetectorClientKey: DependencyKey { + static var liveValue: RepositoryIconDetectorClient { + RepositoryIconDetectorClient( + detect: { rootURL in + RepositoryIconDetector.detect(at: rootURL) + } + ) + } + + /// Detection is opportunistic; "found nothing" is the safe default + /// for previews and for tests that don't care about icons. + static var previewValue: RepositoryIconDetectorClient { + RepositoryIconDetectorClient(detect: { _ in nil }) + } + static var testValue: RepositoryIconDetectorClient { + RepositoryIconDetectorClient(detect: { _ in nil }) + } +} + +extension DependencyValues { + nonisolated var repositoryIconDetector: RepositoryIconDetectorClient { + get { self[RepositoryIconDetectorClientKey.self] } + set { self[RepositoryIconDetectorClientKey.self] = newValue } + } +} diff --git a/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift b/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift new file mode 100644 index 00000000..96309ddf --- /dev/null +++ b/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift @@ -0,0 +1,177 @@ +import AppKit +import Foundation +import ImageIO + +extension RepositoryIconDetector { + /// Bounded, deterministic file-system access for the detector. All + /// probes go through this one type so the traversal budget, symlink + /// policy, containment check, and image validation can't drift apart + /// between platforms. + nonisolated final class Scanner { + /// Directories that never contain product icons but often contain + /// tens of thousands of entries. Compared lowercased. + private static let skippedDirectoryNames: Set = [ + ".git", ".build", ".dart_tool", ".gradle", ".idea", ".svn", ".vscode", + "build", "carthage", "deriveddata", "dist", "node_modules", "out", + "pods", "target", "vendor", + ] + private static let allowedImageExtensions: Set = [ + "png", "jpg", "jpeg", "webp", "ico", "svg", + ] + private static let maxImageBytes = 5 * 1024 * 1024 + private static let maxRasterPixelDimension = 4096 + private static let minRasterPixelDimension = 16 + private static let maxVisitedEntries = 5000 + private static let maxModuleDirectories = 50 + + let rootURL: URL + private let resolvedRootPath: String + private let fileManager: FileManager + private var remainingEntries = Scanner.maxVisitedEntries + + init(rootURL: URL, fileManager: FileManager) { + self.rootURL = rootURL + self.resolvedRootPath = rootURL.resolvingSymlinksInPath().path(percentEncoded: false) + self.fileManager = fileManager + } + + /// Lowercased shallow listing, mirroring `WorktreeProjectKind`'s + /// marker matching. Empty when the directory can't be read. + func entryNames(of directory: URL) -> Set { + guard let entries = try? fileManager.contentsOfDirectory(atPath: directory.path(percentEncoded: false)) + else { + return [] + } + return Set(entries.map { $0.lowercased() }) + } + + /// Sorted plain subdirectory names (no symlinks, skip list applied), + /// capped so a flat repo with thousands of folders stays cheap. + func subdirectoryNames(of directory: URL) -> [String] { + Array(childDirectories(of: directory).map(\.lastPathComponent).prefix(Scanner.maxModuleDirectories)) + } + + func directoryExists(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + /// Breadth-first search for directories whose name matches + /// `lowercasedName`, ordered nearest-to-root first and + /// lexicographically within one depth. Respects the shared entry + /// budget and cooperative cancellation. + func findDirectories(named lowercasedName: String, under directory: URL, maxDepth: Int) -> [URL] { + var found: [URL] = [] + var frontier = [directory] + var depth = 0 + while !frontier.isEmpty, depth < maxDepth, remainingEntries > 0, !Task.isCancelled { + var next: [URL] = [] + for parent in frontier { + for child in childDirectories(of: parent) { + if child.lastPathComponent.lowercased() == lowercasedName { + found.append(child) + } else { + next.append(child) + } + } + } + frontier = next + depth += 1 + } + return found + } + + /// Reads at most `limit` bytes; refuses files that would exceed it + /// rather than truncating a manifest into valid-looking JSON. + func boundedContents(of url: URL, limit: Int) -> Data? { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + let fileSize = values.fileSize, fileSize <= limit + else { + return nil + } + return try? Data(contentsOf: url) + } + + /// Full validation pipeline for a candidate image. Returns the URL + /// when the file is safe to import: contained in the repository + /// after resolving symlinks, a regular file of bounded size, an + /// allowed format, and actually decodable at a sane pixel size. + func validatedImage(at url: URL) -> URL? { + let fileExtension = url.pathExtension.lowercased() + guard Scanner.allowedImageExtensions.contains(fileExtension) else { return nil } + let resolved = url.resolvingSymlinksInPath() + let resolvedPath = resolved.path(percentEncoded: false) + guard resolvedPath.hasPrefix(resolvedRootPath.hasSuffix("/") ? resolvedRootPath : resolvedRootPath + "/") + else { + return nil + } + guard let values = try? resolved.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + let fileSize = values.fileSize, + fileSize > 0, fileSize <= Scanner.maxImageBytes + else { + return nil + } + if fileExtension == "svg" { + return isDecodableSVG(at: resolved) ? url : nil + } + return isDecodableRaster(at: resolved) ? url : nil + } + + // MARK: - Private + + private func childDirectories(of directory: URL) -> [URL] { + guard remainingEntries > 0, + let entries = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) + else { + return [] + } + remainingEntries -= entries.count + return + entries + .filter { url in + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values?.isDirectory == true, values?.isSymbolicLink != true else { return false } + return !Scanner.skippedDirectoryNames.contains(url.lastPathComponent.lowercased()) + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + /// Cheap structural sniff plus a real decode. `NSImage` is the same + /// renderer the app uses later, so a pass here guarantees the icon + /// won't turn into the missing-file placeholder. + private func isDecodableSVG(at url: URL) -> Bool { + guard let prefix = boundedContents(of: url, limit: Scanner.maxImageBytes), + let head = String(data: prefix.prefix(4096), encoding: .utf8), + head.localizedCaseInsensitiveContains(" 0 && image.size.height > 0 + } + + /// Metadata-only probe via ImageIO — no bitmap is decompressed, so + /// a decompression bomb can't hurt us; the pixel cap keeps later + /// rendering bounded too. + private func isDecodableRaster(at url: URL) -> Bool { + let options = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, options), + CGImageSourceGetCount(source) > 0, + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, options) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int + else { + return false + } + return (Scanner.minRasterPixelDimension...Scanner.maxRasterPixelDimension).contains(width) + && (Scanner.minRasterPixelDimension...Scanner.maxRasterPixelDimension).contains(height) + } + } +} diff --git a/supacode/Clients/Repositories/RepositorySymbolSuggestionClient.swift b/supacode/Clients/Repositories/RepositorySymbolSuggestionClient.swift new file mode 100644 index 00000000..16d32414 --- /dev/null +++ b/supacode/Clients/Repositories/RepositorySymbolSuggestionClient.swift @@ -0,0 +1,102 @@ +import Dependencies +import Foundation +import GlyphonKit + +/// Dependency surface for the user-invoked "Suggest an Icon" flow. +/// Results are cached in memory per repository for the app session so +/// reopening the picker shows the previous run instantly; only an +/// explicit Regenerate spends another model call. +nonisolated struct RepositorySymbolSuggestionClient: Sendable { + var cachedSuggestions: @Sendable (_ repositoryID: Repository.ID) async -> RepositorySymbolSuggestions? + /// Builds the input locally, runs GlyphonKit's recommender, caches + /// and returns the result. Cooperatively cancellable — cancelling the + /// surrounding task throws `CancellationError` promptly, even + /// mid-model-call. + var generateSuggestions: + @Sendable ( + _ repositoryID: Repository.ID, + _ rootURL: URL, + _ repositoryDisplayName: String + ) async throws -> RepositorySymbolSuggestions +} + +/// Session-scoped engine behind the live client. An actor so the lazy +/// GlyphonKit database load and the cache stay data-race free; nothing +/// here is persisted to disk. +private actor RepositorySymbolSuggestionEngine { + static let shared = RepositorySymbolSuggestionEngine() + + private var glyphon: Glyphon? + private var cache: [Repository.ID: RepositorySymbolSuggestions] = [:] + + func cached(_ repositoryID: Repository.ID) -> RepositorySymbolSuggestions? { + cache[repositoryID] + } + + func generate( + repositoryID: Repository.ID, + rootURL: URL, + repositoryDisplayName: String + ) async throws -> RepositorySymbolSuggestions { + let input = RepositorySuggestionInput.build( + rootURL: rootURL, + repositoryDisplayName: repositoryDisplayName + ) + let glyphon = try loadGlyphon() + let recommendation = try await glyphon.recommend(from: input.text) + let suggestions = RepositorySymbolSuggestions( + primary: recommendation.symbol.name, + alternates: recommendation.alternates.map(\.name), + reason: recommendation.reason, + source: input.source, + usedAI: recommendation.usedAI + ) + cache[repositoryID] = suggestions + return suggestions + } + + private func loadGlyphon() throws -> Glyphon { + if let glyphon { + return glyphon + } + // Loads the bundled symbol database (~9k symbols) once per session. + let loaded = try Glyphon() + glyphon = loaded + return loaded + } +} + +nonisolated enum RepositorySymbolSuggestionClientKey: DependencyKey { + static var liveValue: RepositorySymbolSuggestionClient { + RepositorySymbolSuggestionClient( + cachedSuggestions: { repositoryID in + await RepositorySymbolSuggestionEngine.shared.cached(repositoryID) + }, + generateSuggestions: { repositoryID, rootURL, repositoryDisplayName in + try await RepositorySymbolSuggestionEngine.shared.generate( + repositoryID: repositoryID, + rootURL: rootURL, + repositoryDisplayName: repositoryDisplayName + ) + } + ) + } + + static var previewValue: RepositorySymbolSuggestionClient { noSuggestions } + static var testValue: RepositorySymbolSuggestionClient { noSuggestions } + + private static var noSuggestions: RepositorySymbolSuggestionClient { + struct Unavailable: Error {} + return RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in throw Unavailable() } + ) + } +} + +extension DependencyValues { + nonisolated var repositorySymbolSuggestionClient: RepositorySymbolSuggestionClient { + get { self[RepositorySymbolSuggestionClientKey.self] } + set { self[RepositorySymbolSuggestionClientKey.self] = newValue } + } +} diff --git a/supacode/Domain/RepositoryAppearance.swift b/supacode/Domain/RepositoryAppearance.swift index 92920220..8159068c 100644 --- a/supacode/Domain/RepositoryAppearance.swift +++ b/supacode/Domain/RepositoryAppearance.swift @@ -10,18 +10,56 @@ import Foundation /// because the sidebar / shelf / canvas all need O(1) cross-repo /// lookups during render and a single `@Shared` dict is the lightest /// way to give every renderer the same view. -nonisolated struct RepositoryAppearance: Codable, Equatable, Hashable, Sendable { +nonisolated struct RepositoryAppearance: Equatable, Hashable, Sendable { var icon: RepositoryIconSource? var color: RepositoryColorChoice? + /// Set when the user explicitly clears the icon. A suppressed + /// repository never accepts an automatic detection result — this is + /// what stops an in-flight detector from resurrecting an icon the + /// user just removed. Removing the repository resets the flag so a + /// future re-add starts fresh. + var iconDetectionSuppressed: Bool static let empty = RepositoryAppearance(icon: nil, color: nil) - init(icon: RepositoryIconSource? = nil, color: RepositoryColorChoice? = nil) { + init( + icon: RepositoryIconSource? = nil, + color: RepositoryColorChoice? = nil, + iconDetectionSuppressed: Bool = false + ) { self.icon = icon self.color = color + self.iconDetectionSuppressed = iconDetectionSuppressed } var isEmpty: Bool { - icon == nil && color == nil + icon == nil && color == nil && !iconDetectionSuppressed + } +} + +extension RepositoryAppearance: Codable { + private enum CodingKeys: String, CodingKey { + case icon + case color + case iconDetectionSuppressed + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + icon = try container.decodeIfPresent(RepositoryIconSource.self, forKey: .icon) + color = try container.decodeIfPresent(RepositoryColorChoice.self, forKey: .color) + iconDetectionSuppressed = + try container.decodeIfPresent(Bool.self, forKey: .iconDetectionSuppressed) ?? false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(icon, forKey: .icon) + try container.encodeIfPresent(color, forKey: .color) + // Omit the default so pre-existing files stay byte-identical until + // a repo actually uses suppression. + if iconDetectionSuppressed { + try container.encode(iconDetectionSuppressed, forKey: .iconDetectionSuppressed) + } } } diff --git a/supacode/Domain/RepositoryIconSource.swift b/supacode/Domain/RepositoryIconSource.swift index bafc3bf8..8e7f31d9 100644 --- a/supacode/Domain/RepositoryIconSource.swift +++ b/supacode/Domain/RepositoryIconSource.swift @@ -11,13 +11,18 @@ import Foundation /// - `userImage`: a file the user dropped in via the picker, stored at /// `~/.prowl/repo//icons/`. Filename includes its /// extension so `isTintable` can distinguish PNG (no tint) from SVG. +/// - `detectedImage`: a project asset found by automatic detection when +/// the repository was added; same on-disk storage as `userImage` but +/// never tinted, so a detected SVG logo keeps its intrinsic colors. nonisolated enum RepositoryIconSource: Equatable, Hashable, Sendable { case sfSymbol(String) case bundledAsset(String) case userImage(filename: String) + case detectedImage(filename: String) static let assetMarker = "@asset:" static let userImageMarker = "@file:" + static let detectedImageMarker = "@detected:" /// Round-tripped form for JSON storage. Bare strings stay SF Symbols /// for forward-compat with anything else that learns the convention. @@ -29,6 +34,8 @@ nonisolated enum RepositoryIconSource: Equatable, Hashable, Sendable { Self.assetMarker + name case .userImage(let filename): Self.userImageMarker + filename + case .detectedImage(let filename): + Self.detectedImageMarker + filename } } @@ -40,25 +47,41 @@ nonisolated enum RepositoryIconSource: Equatable, Hashable, Sendable { if trimmed.hasPrefix(userImageMarker) { return .userImage(filename: String(trimmed.dropFirst(userImageMarker.count))) } + if trimmed.hasPrefix(detectedImageMarker) { + return .detectedImage(filename: String(trimmed.dropFirst(detectedImageMarker.count))) + } if trimmed.hasPrefix(assetMarker) { return .bundledAsset(String(trimmed.dropFirst(assetMarker.count))) } return .sfSymbol(trimmed) } - /// PNG keeps its own colors; SF Symbols and SVGs are tintable. Bundled - /// assets default to non-tintable so future additions don't repaint - /// branded artwork unintentionally — flip per-asset if/when needed. + /// PNG keeps its own colors; SF Symbols and user SVGs are tintable. + /// Bundled assets default to non-tintable so future additions don't + /// repaint branded artwork unintentionally — flip per-asset if/when + /// needed. Detected images are never tinted: a project logo's own + /// colors are the identity signal detection exists to surface. var isTintable: Bool { switch self { case .sfSymbol: true - case .bundledAsset: + case .bundledAsset, .detectedImage: false case .userImage(let filename): filename.lowercased().hasSuffix(".svg") } } + + /// The stored filename for icons backed by a file in the per-repo + /// icons directory, `nil` for symbol- and asset-based icons. + var storedImageFilename: String? { + switch self { + case .sfSymbol, .bundledAsset: + nil + case .userImage(let filename), .detectedImage(let filename): + filename + } + } } extension RepositoryIconSource: Codable { diff --git a/supacode/Domain/RepositorySuggestionInput.swift b/supacode/Domain/RepositorySuggestionInput.swift new file mode 100644 index 00000000..30338da3 --- /dev/null +++ b/supacode/Domain/RepositorySuggestionInput.swift @@ -0,0 +1,148 @@ +import Foundation + +/// Builds the bounded natural-language input for an SF Symbol +/// suggestion run. Sources are tried in a fixed order — cleaned README +/// synopsis, root manifest description, repository display name — and +/// the winner is reported so the UI can disclose it. The synopsis is +/// visible text, not a raw prefix: front matter, badges, fenced code, +/// and markup are dropped before the character cap, which keeps the +/// budget meaningful for CJK prose as well. +nonisolated struct RepositorySuggestionInput: Equatable, Sendable { + /// Matches GlyphonKit's internal prompt budget. + static let maxLength = 600 + /// A README whose visible text is shorter than this is treated as + /// empty (badge-only READMEs are common) and falls through. + static let minimumUsefulLength = 24 + private static let maxSourceBytes = 256 * 1024 + + let text: String + let source: RepositorySymbolSuggestions.Source + + static func build( + rootURL: URL, + repositoryDisplayName: String, + fileManager: FileManager = .default + ) -> RepositorySuggestionInput { + if let synopsis = readmeSynopsis(rootURL: rootURL, fileManager: fileManager) { + return RepositorySuggestionInput(text: synopsis, source: .readme) + } + if let description = manifestDescription(rootURL: rootURL) { + return RepositorySuggestionInput(text: description, source: .manifestDescription) + } + return RepositorySuggestionInput( + text: String(repositoryDisplayName.prefix(maxLength)), + source: .repositoryName + ) + } + + // MARK: - README + + private static func readmeSynopsis(rootURL: URL, fileManager: FileManager) -> String? { + guard let entries = try? fileManager.contentsOfDirectory(atPath: rootURL.path(percentEncoded: false)) + else { + return nil + } + let preferredNames = ["readme.md", "readme.markdown", "readme.mdown", "readme.txt", "readme"] + let readme = preferredNames.lazy + .compactMap { preferred in entries.first { $0.lowercased() == preferred } } + .first + guard let readme, + let raw = boundedText(at: rootURL.appending(path: readme, directoryHint: .notDirectory)) + else { + return nil + } + let cleaned = cleanedMarkdownSynopsis(raw) + guard cleaned.count >= minimumUsefulLength else { return nil } + return cleaned + } + + /// Reduces markdown to the prose a reader actually sees, capped to + /// `maxLength` characters. + static func cleanedMarkdownSynopsis(_ markdown: String) -> String { + var text = markdown + // YAML front matter at the very top. + text = text.replacing(/\A---\n[\s\S]*?\n---\n/, with: "") + // Fenced code blocks, HTML comments, then remaining inline HTML. + text = text.replacing(/(?:```|~~~)[\s\S]*?(?:```|~~~)/, with: " ") + text = text.replacing(//, with: " ") + text = text.replacing(/<[^>\n]+>/, with: " ") + // Images (badges) vanish entirely; links keep their visible text. + text = text.replacing(/!\[[^\]]*\]\([^)]*\)/, with: " ") + text = text.replacing(/\[([^\]]*)\]\([^)]*\)/) { match in String(match.output.1) } + // Reference-style link definitions occupy whole lines. + text = text.replacing(/^\s*\[[^\]]+\]:\s+\S+.*$/.anchorsMatchLineEndings(), with: " ") + // Markup characters that survive the structural passes. + text = text.replacing(/[#>*_`|]+/, with: " ") + text = text.replacing(/\s+/, with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return String(text.prefix(maxLength)) + } + + // MARK: - Manifest descriptions + + private static func manifestDescription(rootURL: URL) -> String? { + if let description = packageJSONDescription(rootURL: rootURL) { + return description + } + if let description = pubspecDescription(rootURL: rootURL) { + return description + } + if let description = cargoDescription(rootURL: rootURL) { + return description + } + return nil + } + + private static func packageJSONDescription(rootURL: URL) -> String? { + struct Manifest: Decodable { + let description: String? + } + let url = rootURL.appending(path: "package.json", directoryHint: .notDirectory) + guard let data = boundedData(at: url), + let manifest = try? JSONDecoder().decode(Manifest.self, from: data) + else { + return nil + } + return normalizedDescription(manifest.description) + } + + private static func pubspecDescription(rootURL: URL) -> String? { + let url = rootURL.appending(path: "pubspec.yaml", directoryHint: .notDirectory) + guard let text = boundedText(at: url), + let match = text.firstMatch(of: /^description:\s*(?:>-?\s*)?["']?([^"'\n]+)["']?\s*$/.anchorsMatchLineEndings()) + else { + return nil + } + return normalizedDescription(String(match.output.1)) + } + + private static func cargoDescription(rootURL: URL) -> String? { + let url = rootURL.appending(path: "Cargo.toml", directoryHint: .notDirectory) + guard let text = boundedText(at: url), + let match = text.firstMatch(of: /^description\s*=\s*"([^"\n]+)"/.anchorsMatchLineEndings()) + else { + return nil + } + return normalizedDescription(String(match.output.1)) + } + + private static func normalizedDescription(_ description: String?) -> String? { + guard let description else { return nil } + let trimmed = description.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(maxLength)) + } + + // MARK: - Bounded IO + + private static func boundedData(at url: URL) -> Data? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + return try? handle.read(upToCount: maxSourceBytes) + } + + private static func boundedText(at url: URL) -> String? { + guard let data = boundedData(at: url) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/supacode/Domain/RepositorySymbolSuggestions.swift b/supacode/Domain/RepositorySymbolSuggestions.swift new file mode 100644 index 00000000..2c6a70ff --- /dev/null +++ b/supacode/Domain/RepositorySymbolSuggestions.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Result of a user-invoked "Suggest an Icon" run for one repository: +/// GlyphonKit's best SF Symbol pick, its alternates, and the reasoning +/// line, plus enough provenance for honest labeling in the picker. +nonisolated struct RepositorySymbolSuggestions: Equatable, Sendable { + /// Which local input produced the suggestion text, disclosed in the + /// picker (`Based on README`, …). Sources are tried in this order and + /// never blindly concatenated. + enum Source: Equatable, Sendable { + case readme + case manifestDescription + case repositoryName + + var disclosureLabel: String { + switch self { + case .readme: + "Based on README" + case .manifestDescription: + "Based on the package description" + case .repositoryName: + "Based on the repository name" + } + } + } + + var primary: String + var alternates: [String] + var reason: String + var source: Source + /// `false` marks a retrieval-only fallback (Foundation Models + /// unavailable or degraded) — the UI must label those as keyword + /// suggestions, never as model recommendations. + var usedAI: Bool + + var allSymbols: [String] { + [primary] + alternates + } +} diff --git a/supacode/Domain/WorktreeProjectKind.swift b/supacode/Domain/WorktreeProjectKind.swift index 31a621b0..3eaccb9c 100644 --- a/supacode/Domain/WorktreeProjectKind.swift +++ b/supacode/Domain/WorktreeProjectKind.swift @@ -3,6 +3,8 @@ import Foundation /// Project ecosystems Prowl can recognize from a worktree's top-level files, /// used to pick a fitting app when the open action is set to Automatic. enum WorktreeProjectKind: CaseIterable { + case flutter + case reactNative case apple case android case dotnet @@ -19,6 +21,9 @@ enum WorktreeProjectKind: CaseIterable { /// Checks run from the most specific marker to the least: `package.json` is /// last because nearly any repo can carry one for tooling, while an /// `.xcodeproj` or Gradle script identifies the project unambiguously. + /// Flutter and React Native run first: their repos wrap native `ios/` / + /// `android/` shells, so the hybrid kind is the more specific claim. Both + /// require a positive manifest signal, not just a directory layout. static func detect(at directory: URL, fileManager: FileManager = .default) -> WorktreeProjectKind? { guard let entries = try? fileManager.contentsOfDirectory(atPath: directory.path) else { return nil @@ -27,6 +32,15 @@ enum WorktreeProjectKind: CaseIterable { func hasFile(withExtension ext: String) -> Bool { names.contains { $0.hasSuffix(".\(ext)") } } + if names.contains("pubspec.yaml"), pubspecDeclaresFlutter(in: directory) { + return .flutter + } + if names.contains("package.json"), + names.contains("ios") || names.contains("android"), + packageJSONDependsOnReactNative(in: directory) + { + return .reactNative + } if hasFile(withExtension: "xcodeproj") || hasFile(withExtension: "xcworkspace") || names.contains("package.swift") || names.contains("project.swift") { @@ -70,10 +84,59 @@ enum WorktreeProjectKind: CaseIterable { return nil } + /// Reads a bounded prefix of a marker file so detection stays cheap even + /// when a repo carries a pathological manifest. + nonisolated private static func markerFileContents(named name: String, in directory: URL) -> String? { + let url = directory.appending(path: name, directoryHint: .notDirectory) + guard let handle = try? FileHandle(forReadingFrom: url), + let data = try? handle.read(upToCount: 128 * 1024) + else { + return nil + } + try? handle.close() + return String(data: data, encoding: .utf8) + } + + /// A `pubspec.yaml` alone is any Dart package; Flutter needs the + /// `flutter:` key (dependency or top-level section) as positive proof. + /// Internal because `RepositoryIconDetector` reuses the same signal. + nonisolated static func pubspecDeclaresFlutter(in directory: URL) -> Bool { + guard let contents = markerFileContents(named: "pubspec.yaml", in: directory) else { + return false + } + return contents.contains(/^\s*flutter\s*:/.anchorsMatchLineEndings()) + } + + /// React Native needs `react-native` as a declared dependency; the + /// `ios`/`android` shell folders alone are checked by the caller. + /// Internal because `RepositoryIconDetector` reuses the same signal. + nonisolated static func packageJSONDependsOnReactNative(in directory: URL) -> Bool { + struct Manifest: Decodable { + let dependencies: [String: String]? + let devDependencies: [String: String]? + } + let url = directory.appending(path: "package.json", directoryHint: .notDirectory) + guard let data = try? Data(contentsOf: url), data.count <= 512 * 1024, + let manifest = try? JSONDecoder().decode(Manifest.self, from: data) + else { + return false + } + return manifest.dependencies?.keys.contains("react-native") == true + || manifest.devDependencies?.keys.contains("react-native") == true + } + + /// VS Code-style editors in `OpenWorktreeAction.editorPriority` order, + /// used by the hybrid kinds whose tooling docs recommend VS Code. + private static let vsCodeFamily: [OpenWorktreeAction] = [ + .cursor, .vscode, .windsurf, .vscodeInsiders, .vscodium, + ] + /// Apps to try before `OpenWorktreeAction.defaultPriority` when resolving /// the Automatic open action for this project kind. var preferredActions: [OpenWorktreeAction] { switch self { + case .flutter: [.androidStudio, .intellij, .intellijEAP] + Self.vsCodeFamily + case .reactNative: Self.vsCodeFamily + [.webstorm, .androidStudio] case .apple: [.xcode] case .android: [.androidStudio, .intellij, .intellijEAP] case .dotnet: [.rider] diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index d360d9cc..2ef6de47 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -443,7 +443,12 @@ struct AppFeature { customCommands: state.selectedCustomCommands ) let badgeCount = settings.showNotificationDotOnDock ? state.notificationIndicatorCount : 0 + let cancelIconDetections: Effect = + settings.detectRepositoryIconsAutomatically + ? .none + : .send(.repositories(.repositoryManagement(.cancelPendingIconDetections))) return .merge( + cancelIconDetections, .send(.repositories(.githubIntegration(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled)))), .send( .repositories( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift new file mode 100644 index 00000000..ca3dffb9 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift @@ -0,0 +1,120 @@ +import ComposableArchitecture +import Foundation + +/// Automatic repository icon detection lifecycle. Scans run only for +/// repositories newly added in the current operation, at utility +/// priority, and never block loading or selection. A result commits +/// only when the repository still exists, detection is still enabled, +/// and no manual icon or explicit clear got there first. +extension RepositoriesFeature { + /// Adds are interactive and small; a hard cap keeps a bulk drag-in + /// from fanning out unbounded file-system scans. + static let maxIconDetectionsPerAdd = 8 + + /// One shared cancellation umbrella over every in-flight scan, so a + /// global opt-out cancels them all without per-repo bookkeeping. + static let iconDetectionUmbrellaID = "repositories.iconDetection" + + /// Spawns one cancellable, utility-priority detection per eligible + /// newly added repository. Workspace containers are never scanned; + /// repositories with any existing icon or a recorded suppression are + /// skipped up front. A scan that finds nothing ends silently. + func iconDetectionEffects(for newRepositories: [Repository]) -> [Effect] { + @Shared(.settingsFile) var settingsFile + guard settingsFile.global.detectRepositoryIconsAutomatically else { return [] } + @Shared(.repositoryAppearances) var appearances + var effects: [Effect] = [] + for repository in newRepositories.prefix(Self.maxIconDetectionsPerAdd) { + guard repository.workspace == nil else { continue } + let appearance = appearances[repository.id] ?? .empty + guard appearance.icon == nil, !appearance.iconDetectionSuppressed else { continue } + let repositoryID = repository.id + let rootURL = repository.rootURL + let detector = repositoryIconDetector + let assetStore = repositoryIconAssetStore + effects.append( + .run(priority: .utility) { send in + guard let candidate = await detector.detect(rootURL), !Task.isCancelled, + let filename = try? assetStore.importImage(candidate.imageURL, rootURL) + else { + return + } + await send( + .repositoryManagement(.repositoryIconDetected(repositoryID, filename: filename)) + ) + } + .cancellable(id: CancelID.iconDetection(repositoryID), cancelInFlight: true) + .cancellable(id: Self.iconDetectionUmbrellaID) + ) + } + return effects + } + + /// Commit point for a successful scan. All guards re-run against the + /// current world because the scan raced user actions: the repository + /// may be gone, detection may have been disabled, and the user may + /// have picked or cleared an icon. On any failed guard the imported + /// asset is deleted instead of committed. + func reduceIconDetected( + state: inout State, + repositoryID: Repository.ID, + filename: String + ) -> Effect { + @Shared(.settingsFile) var settingsFile + @Shared(.repositoryAppearances) var appearances + let repository = state.repositories[id: repositoryID] + let appearance = appearances[repositoryID] ?? .empty + guard repository != nil, + settingsFile.global.detectRepositoryIconsAutomatically, + appearance.icon == nil, + !appearance.iconDetectionSuppressed + else { + let rootURL = repository?.rootURL ?? URL(fileURLWithPath: repositoryID, isDirectory: true) + let assetStore = repositoryIconAssetStore + return .run { _ in + try? assetStore.remove(filename, rootURL) + } + } + $appearances.withLock { + var updated = $0[repositoryID] ?? .empty + updated.icon = .detectedImage(filename: filename) + $0[repositoryID] = updated + } + return .none + } + + /// Removal cleanup: cancel a pending scan and drop automatic + /// detection artifacts (the detected image asset and the suppression + /// flag) while preserving a user-selected icon and color, so a + /// removed-and-re-added repository starts detection fresh. + func iconDetectionRemovalEffect( + repositoryID: Repository.ID, + rootURL: URL? + ) -> Effect { + @Shared(.repositoryAppearances) var appearances + var removedFilename: String? + if let appearance = appearances[repositoryID] { + var updated = appearance + if case .detectedImage(let filename) = updated.icon { + removedFilename = filename + updated.icon = nil + } + updated.iconDetectionSuppressed = false + if updated != appearance { + $appearances.withLock { + $0[repositoryID] = updated.isEmpty ? nil : updated + } + } + } + let cancel: Effect = .cancel(id: CancelID.iconDetection(repositoryID)) + guard let removedFilename else { return cancel } + let resolvedRootURL = rootURL ?? URL(fileURLWithPath: repositoryID, isDirectory: true) + let assetStore = repositoryIconAssetStore + return .merge( + cancel, + .run { _ in + try? assetStore.remove(removedFilename, resolvedRootURL) + } + ) + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 857f1915..63ba73ee 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -174,6 +174,7 @@ extension RepositoriesFeature { allEffects.append(.send(.selectRepository(firstNew.id))) } } + allEffects.append(contentsOf: iconDetectionEffects(for: Array(newRepos))) } return .merge(allEffects) @@ -326,23 +327,30 @@ extension RepositoriesFeature { state.repositoryRoots.removeAll { isSameRepositoryPath($0.standardizedFileURL.path(percentEncoded: false), repositoryID) } + let failedIconCleanup = iconDetectionRemovalEffect( + repositoryID: repositoryID, + rootURL: state.repositories[id: repositoryID]?.rootURL + ) let remainingRoots = state.repositoryRoots - return .run { send in - let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) - let remainingEntries = loadedEntries.filter { !isSameRepositoryPath($0.path, repositoryID) } - await repositoryPersistence.saveRepositoryEntries(remainingEntries) - let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } - let (repositories, failures) = await loadRepositoriesData(remainingEntries) - await send( - .repositoriesLoaded( - repositories, - failures: failures, - roots: roots, - animated: true + return .merge( + failedIconCleanup, + .run { send in + let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) + let remainingEntries = loadedEntries.filter { !isSameRepositoryPath($0.path, repositoryID) } + await repositoryPersistence.saveRepositoryEntries(remainingEntries) + let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } + let (repositories, failures) = await loadRepositoriesData(remainingEntries) + await send( + .repositoriesLoaded( + repositories, + failures: failures, + roots: roots, + animated: true + ) ) - ) - } - .cancellable(id: CancelID.load, cancelInFlight: true) + } + .cancellable(id: CancelID.load, cancelInFlight: true) + ) case .repositoryRemoved(let repositoryID, let selectionWasRemoved): analyticsClient.capture("repository_removed", [String: Any]?.none) @@ -352,9 +360,14 @@ extension RepositoriesFeature { state.selectedWorkspaceChildID = nil state.shouldSelectFirstAfterReload = true } + let iconCleanup = iconDetectionRemovalEffect( + repositoryID: repositoryID, + rootURL: state.repositories[id: repositoryID]?.rootURL + ) let selectedWorktree = state.worktree(for: state.selectedWorktreeID) let remainingRoots = state.repositoryRoots return .merge( + iconCleanup, .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), .run { send in let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) @@ -376,6 +389,12 @@ extension RepositoriesFeature { case .openRepositorySettings(let repositoryID): return .send(.delegate(.openRepositorySettings(repositoryID))) + + case .repositoryIconDetected(let repositoryID, let filename): + return reduceIconDetected(state: &state, repositoryID: repositoryID, filename: filename) + + case .cancelPendingIconDetections: + return .cancel(id: Self.iconDetectionUmbrellaID) } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index ea2bd105..932c0535 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -113,6 +113,9 @@ struct RepositoriesFeature { "repositories.delayedPRRefresh.\(worktreeID)" } static let branchNameSuggestion = "repositories.branchNameSuggestion" + static func iconDetection(_ repositoryID: Repository.ID) -> String { + "repositories.iconDetection.\(repositoryID)" + } } @CasePathable @@ -253,6 +256,11 @@ struct RepositoriesFeature { ) case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) case openRepositorySettings(Repository.ID) + /// `filename` is the already-imported icon asset; scans that find + /// no valid candidate end silently. Commit guards run in the + /// reducer, which deletes the asset again when a guard fails. + case repositoryIconDetected(Repository.ID, filename: String) + case cancelPendingIconDetections } @CasePathable @@ -548,6 +556,8 @@ struct RepositoriesFeature { @Dependency(\.date.now) var now @Dependency(BranchNameSuggestionClient.self) var branchNameSuggestionClient @Dependency(\.uuid) var uuid + @Dependency(\.repositoryIconDetector) var repositoryIconDetector + @Dependency(\.repositoryIconAssetStore) var repositoryIconAssetStore var body: some Reducer { CombineReducers { diff --git a/supacode/Features/Repositories/Views/RepositoryIconImage.swift b/supacode/Features/Repositories/Views/RepositoryIconImage.swift index 19cac839..9381a7dc 100644 --- a/supacode/Features/Repositories/Views/RepositoryIconImage.swift +++ b/supacode/Features/Repositories/Views/RepositoryIconImage.swift @@ -53,7 +53,7 @@ struct RepositoryIconImage: View { .resizable() .aspectRatio(contentMode: .fit) .accessibilityHidden(true) - case .userImage(let filename): + case .userImage(let filename), .detectedImage(let filename): userImage(filename: filename) } } diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 7a7ca10e..3670d82d 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -3,6 +3,22 @@ import Foundation @Reducer struct RepositorySettingsFeature { + private enum CancelID { + static let symbolSuggestions = "repositorySettings.symbolSuggestions" + } + + /// Lifecycle of the "Suggested for this repository" section inside + /// the symbol picker sheet. Successful results also live in the + /// session cache owned by `RepositorySymbolSuggestionClient`, so + /// `.idle` on a fresh State still resolves instantly when a cached + /// run exists. + enum SymbolSuggestionsPhase: Equatable { + case idle + case loading + case loaded(RepositorySymbolSuggestions) + case failed(String) + } + @ObservableState struct State: Equatable { var rootURL: URL @@ -26,6 +42,8 @@ struct RepositorySettingsFeature { var isBranchDataLoaded = false var keybindingUserOverrides: KeybindingUserOverrideStore = .empty var appearanceImportError: String? + var isSymbolPickerPresented = false + var symbolSuggestions: SymbolSuggestionsPhase = .idle var capabilities: Repository.Capabilities { switch repositoryKind { @@ -92,6 +110,12 @@ struct RepositorySettingsFeature { case appearanceLoaded(RepositoryAppearance) case setAppearanceColor(RepositoryColorChoice?) case setAppearanceIcon(RepositoryIconSource?) + case chooseSymbolTapped + case suggestIconTapped + case regenerateSuggestionsTapped + case symbolPickerDismissed + case symbolSuggestionsLoaded(RepositorySymbolSuggestions) + case symbolSuggestionsFailed(String) case importUserImage(URL) case userImageImported(filename: String) case userImageImportFailed(String) @@ -110,6 +134,7 @@ struct RepositorySettingsFeature { @Dependency(GitClientDependency.self) private var gitClient @Dependency(\.repositoryIconAssetStore) private var repositoryIconAssetStore + @Dependency(\.repositorySymbolSuggestionClient) private var symbolSuggestionClient var body: some Reducer { BindingReducer() @@ -227,6 +252,11 @@ struct RepositorySettingsFeature { let previousIcon = state.appearance.icon guard previousIcon != newIcon else { return .none } state.appearance.icon = newIcon + if newIcon == nil { + // An explicit clear also suppresses automatic detection so an + // in-flight detector can't restore the icon the user removed. + state.appearance.iconDetectionSuppressed = true + } let persist = persistAppearance(state.appearance, repositoryID: state.repositoryID) let cleanup = removeAbandonedUserImage( previous: previousIcon, @@ -258,11 +288,48 @@ struct RepositorySettingsFeature { state.appearanceImportError = nil return .none + case .chooseSymbolTapped: + state.isSymbolPickerPresented = true + guard case .idle = state.symbolSuggestions else { return .none } + // Surface a cached run if one exists; otherwise the section + // stays idle with an explicit Suggest button. + let repositoryID = state.repositoryID + let client = symbolSuggestionClient + return .run { send in + if let cached = await client.cachedSuggestions(repositoryID) { + await send(.symbolSuggestionsLoaded(cached)) + } + } + + case .suggestIconTapped: + state.isSymbolPickerPresented = true + switch state.symbolSuggestions { + case .loading, .loaded: + return .none + case .idle, .failed: + state.symbolSuggestions = .loading + return suggestionsEffect(state: state, forceRegenerate: false) + } + + case .regenerateSuggestionsTapped: + state.symbolSuggestions = .loading + return suggestionsEffect(state: state, forceRegenerate: true) + + case .symbolSuggestionsLoaded(let suggestions): + state.symbolSuggestions = .loaded(suggestions) + return .none + + case .symbolSuggestionsFailed(let message): + state.symbolSuggestions = .failed(message) + return .none + case .resetAppearance: let previousIcon = state.appearance.icon guard !state.appearance.isEmpty else { return .none } - state.appearance = .empty - let persist = persistAppearance(.empty, repositoryID: state.repositoryID) + // Removing an icon via reset counts as an explicit clear for + // detection purposes; a reset that only dropped a color does not. + state.appearance = RepositoryAppearance(iconDetectionSuppressed: previousIcon != nil) + let persist = persistAppearance(state.appearance, repositoryID: state.repositoryID) let cleanup = removeAbandonedUserImage( previous: previousIcon, new: nil, @@ -283,6 +350,14 @@ struct RepositorySettingsFeature { state.isBranchDataLoaded = true return .none + case .symbolPickerDismissed: + state.isSymbolPickerPresented = false + return reduceSymbolPickerDismissal(state: &state) + + case .binding(\.isSymbolPickerPresented): + guard !state.isSymbolPickerPresented else { return .none } + return reduceSymbolPickerDismissal(state: &state) + case .binding: if state.isBareRepository { state.settings.copyIgnoredOnWorktreeCreate = nil @@ -313,6 +388,46 @@ struct RepositorySettingsFeature { } } + /// Sheet dismissed: cancel an in-flight generation and reset a + /// transient loading state so reopening starts cleanly (a finished + /// run stays visible — it's also session-cached). + private func reduceSymbolPickerDismissal(state: inout State) -> Effect { + if case .loading = state.symbolSuggestions { + state.symbolSuggestions = .idle + } + return .cancel(id: CancelID.symbolSuggestions) + } + + /// Kicks off (or resolves from cache) one suggestion run. The model + /// call is cooperatively cancellable; closing the sheet cancels it + /// via `CancelID.symbolSuggestions`. + private func suggestionsEffect(state: State, forceRegenerate: Bool) -> Effect { + let repositoryID = state.repositoryID + let rootURL = state.rootURL + let displayName: String = + if let custom = state.settings.customTitle, !custom.isEmpty { + custom + } else { + state.rootURL.lastPathComponent + } + let client = symbolSuggestionClient + return .run { send in + if !forceRegenerate, let cached = await client.cachedSuggestions(repositoryID) { + await send(.symbolSuggestionsLoaded(cached)) + return + } + do { + let suggestions = try await client.generateSuggestions(repositoryID, rootURL, displayName) + await send(.symbolSuggestionsLoaded(suggestions)) + } catch is CancellationError { + // Sheet closed mid-generation — nothing to report. + } catch { + await send(.symbolSuggestionsFailed(error.localizedDescription)) + } + } + .cancellable(id: CancelID.symbolSuggestions, cancelInFlight: true) + } + /// Writes the appearance back to the global `@Shared` dict, dropping /// the entry when it's been cleared so the on-disk file stays tight. private func persistAppearance( @@ -331,17 +446,17 @@ struct RepositorySettingsFeature { } } - /// When the icon transitions away from a user-imported file, the old - /// asset on disk is no longer referenced and should be cleaned up. - /// No-op when the previous icon wasn't a user image or when the new - /// icon is the same user image. + /// When the icon transitions away from a file-backed source (user + /// import or automatic detection), the old asset on disk is no longer + /// referenced and should be cleaned up. No-op when the previous icon + /// wasn't file-backed or when the new icon keeps the same file. private func removeAbandonedUserImage( previous: RepositoryIconSource?, new: RepositoryIconSource?, rootURL: URL ) -> Effect { - guard case .userImage(let oldFilename) = previous else { return .none } - if case .userImage(let newFilename) = new, newFilename == oldFilename { + guard let oldFilename = previous?.storedImageFilename else { return .none } + if new?.storedImageFilename == oldFilename { return .none } let store = repositoryIconAssetStore diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index c894881c..bd4997b6 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -17,7 +17,6 @@ import UniformTypeIdentifiers struct RepositoryAppearancePickerView: View { @Bindable var store: StoreOf - @State private var isSymbolPickerPresented = false @State private var isHoveringIconTile = false /// Retains the AppKit controller that drives the shared color panel for the /// custom-color swatch. `NSColorPanel` keeps its target weakly, so this must @@ -46,7 +45,7 @@ struct RepositoryAppearancePickerView: View { importErrorBanner(message: message) } } - .sheet(isPresented: $isSymbolPickerPresented) { + .sheet(isPresented: $store.isSymbolPickerPresented) { TabIconPickerView( initialIcon: currentSymbolName, defaultIcon: "folder.fill", @@ -55,8 +54,23 @@ struct RepositoryAppearancePickerView: View { "Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted " + "with the repo color; bitmap formats keep their own colors.", presets: RepositoryIconPresets.presets, + suggestionsSection: { symbolName in + AnyView( + RepositorySymbolSuggestionsSection( + phase: store.symbolSuggestions, + onSuggest: { + if case .loaded = store.symbolSuggestions { + store.send(.regenerateSuggestionsTapped) + } else { + store.send(.suggestIconTapped) + } + }, + onPick: { symbolName.wrappedValue = $0 } + ) + ) + }, onApply: { applySymbolFromPicker($0) }, - onCancel: { isSymbolPickerPresented = false } + onCancel: { dismissSymbolPicker() } ) } } @@ -99,7 +113,10 @@ struct RepositoryAppearancePickerView: View { private var iconMenu: some View { Menu { Button("Choose Symbol…") { - isSymbolPickerPresented = true + store.send(.chooseSymbolTapped) + } + Button("Suggest an Icon…") { + store.send(.suggestIconTapped) } Button("Choose Image…") { presentImageImporter() @@ -181,6 +198,8 @@ struct RepositoryAppearancePickerView: View { return "Bitmap icons keep their original colors and ignore the repo color." case .userImage: return "User-provided SVGs are tinted with the repo color." + case .detectedImage: + return "Detected automatically from this project's assets. It keeps its original colors." case .sfSymbol: return "SF Symbols pick up the repo color when one is set." case .bundledAsset: @@ -386,7 +405,7 @@ struct RepositoryAppearancePickerView: View { // MARK: - Actions private func applySymbolFromPicker(_ name: String?) { - isSymbolPickerPresented = false + dismissSymbolPicker() if let name { store.send(.setAppearanceIcon(.sfSymbol(name))) } else { @@ -394,6 +413,12 @@ struct RepositoryAppearancePickerView: View { } } + /// Dismissal goes through the reducer so an in-flight suggestion run + /// is cancelled in one place. + private func dismissSymbolPicker() { + store.send(.symbolPickerDismissed) + } + // Uses `NSOpenPanel` (rather than SwiftUI's `.fileImporter`) so the // panel can open at the repo's working directory — most users keep // their icon assets next to the project. Accepts any image UTType — diff --git a/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift b/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift new file mode 100644 index 00000000..1c287e85 --- /dev/null +++ b/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// "Suggested for this repository" block inside the repository symbol +/// picker sheet. Renders the whole suggestion lifecycle: an idle state +/// with an explicit Suggest button, an inline loading state that never +/// blocks manual selection, and the finished results with the best +/// pick, alternates, the reasoning line, and an honest source label. +struct RepositorySymbolSuggestionsSection: View { + let phase: RepositorySettingsFeature.SymbolSuggestionsPhase + /// Starts a run (idle/failed) or re-runs generation (loaded). + let onSuggest: () -> Void + /// Fills the picker's symbol field with the tapped suggestion; the + /// user still confirms with the sheet's Done button. + let onPick: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Suggested for this repository") + .font(.subheadline.weight(.medium)) + Spacer(minLength: 0) + switch phase { + case .idle: + suggestButton(title: "Suggest", help: "Suggest SF Symbols for this repository using on-device intelligence.") + case .loading: + EmptyView() + case .loaded: + suggestButton(title: "Regenerate", help: "Run the on-device suggestion again for fresh candidates.") + case .failed: + suggestButton(title: "Retry", help: "Try generating suggestions again.") + } + } + switch phase { + case .idle: + Text("Uses this project's README to propose fitting symbols. Everything runs on-device.") + .font(.caption) + .foregroundStyle(.secondary) + case .loading: + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Generating suggestions…") + .font(.caption) + .foregroundStyle(.secondary) + } + case .loaded(let suggestions): + loadedContent(suggestions) + case .failed: + Text("Couldn't generate suggestions. You can retry, or pick a symbol manually below.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + } + + @ViewBuilder + private func loadedContent(_ suggestions: RepositorySymbolSuggestions) -> some View { + HStack(spacing: 6) { + ForEach(suggestions.allSymbols, id: \.self) { symbol in + Button { + onPick(symbol) + } label: { + Image(systemName: symbol) + .imageScale(.medium) + .frame(width: 32, height: 32) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke( + symbol == suggestions.primary ? Color.accentColor.opacity(0.6) : Color.clear, + lineWidth: 1.5 + ) + ) + .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .accessibilityHidden(true) + } + .buttonStyle(.plain) + .help(symbol) + } + Spacer(minLength: 0) + } + Text(suggestions.reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + Text(sourceLabel(suggestions)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + private func sourceLabel(_ suggestions: RepositorySymbolSuggestions) -> String { + // Retrieval-only fallbacks must never masquerade as model output. + suggestions.usedAI + ? suggestions.source.disclosureLabel + : "Keyword suggestions · \(suggestions.source.disclosureLabel)" + } + + @ViewBuilder + private func suggestButton(title: String, help: String) -> some View { + Button(title) { + onSuggest() + } + .controlSize(.small) + .help(help) + } +} diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 5627abd0..cc158ce1 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -42,6 +42,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var shelfSpineTintFollowsRepositoryColor: Bool var externalDiffToolID: String = ExternalDiffTool.builtIn.settingsID var externalDiffCustomCommand: String = "" + var detectRepositoryIconsAutomatically: Bool = true static let `default` = GlobalSettings( appearanceMode: .dark, @@ -218,6 +219,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(shelfSpineTintFollowsRepositoryColor, forKey: .shelfSpineTintFollowsRepositoryColor) try container.encode(externalDiffToolID, forKey: .externalDiffToolID) try container.encode(externalDiffCustomCommand, forKey: .externalDiffCustomCommand) + try container.encode(detectRepositoryIconsAutomatically, forKey: .detectRepositoryIconsAutomatically) } private enum CodingKeys: String, CodingKey { @@ -264,6 +266,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case shelfSpineTintFollowsRepositoryColor case externalDiffToolID case externalDiffCustomCommand + case detectRepositoryIconsAutomatically // Legacy keys for migration case automaticallyArchiveMergedWorktrees case notificationSoundEnabled @@ -359,6 +362,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { (windowTintMode, windowTintCustomColor) = try Self.decodeWindowTint(from: container) (shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor) = try Self.decodeShelfSpineTint(from: container) (externalDiffToolID, externalDiffCustomCommand) = try Self.decodeExternalDiffSettings(from: container) + detectRepositoryIconsAutomatically = + try container.decodeIfPresent(Bool.self, forKey: .detectRepositoryIconsAutomatically) + ?? true let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) showRunButtonInToolbar = toolbarAndDock.showRunButtonInToolbar showDefaultEditorInToolbar = toolbarAndDock.showDefaultEditorInToolbar diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 3eeb5117..3369041a 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -52,6 +52,7 @@ struct SettingsFeature { var showNotificationDotOnDock: Bool var externalDiffToolID: String var externalDiffCustomCommand: String + var detectRepositoryIconsAutomatically: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true /// Whether macOS will render the Dock notification badge (notification @@ -109,6 +110,7 @@ struct SettingsFeature { showNotificationDotOnDock = settings.showNotificationDotOnDock externalDiffToolID = settings.externalDiffToolID externalDiffCustomCommand = settings.externalDiffCustomCommand + detectRepositoryIconsAutomatically = settings.detectRepositoryIconsAutomatically } var globalSettings: GlobalSettings { @@ -159,6 +161,7 @@ struct SettingsFeature { ) settings.externalDiffToolID = externalDiffToolID settings.externalDiffCustomCommand = externalDiffCustomCommand + settings.detectRepositoryIconsAutomatically = detectRepositoryIconsAutomatically return settings } } diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 44753518..6727b6cb 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -81,6 +81,19 @@ struct AppearanceSettingsView: View { .font(.callout) .foregroundStyle(.secondary) } + Section("Repository Icons") { + Toggle( + "Detect project icons automatically", + isOn: $store.detectRepositoryIconsAutomatically + ) + .help("Use a project's own app icon or logo as the repository icon when adding it.") + Text( + "Detection runs locally when a repository is added. It never replaces an icon " + + "you picked, and turning it off leaves already detected icons unchanged." + ) + .foregroundStyle(.secondary) + .font(.callout) + } Section("Splits") { Toggle( "Dim unfocused split panes", diff --git a/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift b/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift index f3a9e8e6..644df095 100644 --- a/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift @@ -7,6 +7,13 @@ struct TabIconPickerView: View { let title: String let subtitle: String let presets: [String] + /// Optional host-provided section rendered between the header and + /// the symbol field — the repository picker injects its + /// "Suggested for this repository" content here. Receives a binding + /// to the symbol name so tapping a suggestion fills the field. + /// Type-erased because a generic parameter would forbid this type's + /// static preset table. + let suggestionsSection: ((Binding) -> AnyView)? let onApply: (String?) -> Void let onCancel: () -> Void @@ -19,6 +26,7 @@ struct TabIconPickerView: View { title: String = "Tab Icon", subtitle: String = "Pick a preset or enter any SF Symbol name available in your system.", presets: [String] = TabIconPickerView.symbolPresets, + suggestionsSection: ((Binding) -> AnyView)? = nil, onApply: @escaping (String?) -> Void, onCancel: @escaping () -> Void ) { @@ -27,6 +35,7 @@ struct TabIconPickerView: View { self.title = title self.subtitle = subtitle self.presets = presets + self.suggestionsSection = suggestionsSection self.onApply = onApply self.onCancel = onCancel _symbolName = State(initialValue: initialIcon ?? "") @@ -42,6 +51,10 @@ struct TabIconPickerView: View { .foregroundStyle(.secondary) } + if let suggestionsSection { + suggestionsSection($symbolName) + } + HStack(spacing: 10) { Image(systemName: previewSymbol) .imageScale(.large) diff --git a/supacodeTests/RepositoriesFeatureIconDetectionTests.swift b/supacodeTests/RepositoriesFeatureIconDetectionTests.swift new file mode 100644 index 00000000..5dd9862f --- /dev/null +++ b/supacodeTests/RepositoriesFeatureIconDetectionTests.swift @@ -0,0 +1,451 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import Sharing +import Testing + +@testable import supacode + +@MainActor +struct RepositoriesFeatureIconDetectionTests { + // MARK: - Fixtures + + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { + Worktree( + id: id, + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot), + createdAt: nil + ) + } + + private func makeRepository( + id: String, + name: String = "repo", + kind: Repository.Kind = .git, + worktrees: [Worktree] = [], + workspace: ProjectWorkspace? = nil + ) -> Repository { + Repository( + id: id, + rootURL: URL(fileURLWithPath: id), + name: name, + kind: kind, + worktrees: IdentifiedArray(uniqueElements: worktrees), + workspace: workspace + ) + } + + private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { + var state = RepositoriesFeature.State() + state.repositories = IdentifiedArray(uniqueElements: repositories) + state.repositoryRoots = repositories.map(\.rootURL) + state.isInitialLoadComplete = true + state.snapshotPersistencePhase = .active + return state + } + + private func recordingAssetStore( + imported: LockIsolated<[URL]> = LockIsolated([]), + removed: LockIsolated<[String]> = LockIsolated([]), + importedFilename: String = "detected.png" + ) -> RepositoryIconAssetStore { + RepositoryIconAssetStore( + importImage: { source, _ in + imported.withValue { $0.append(source) } + return importedFilename + }, + remove: { filename, _ in + removed.withValue { $0.append(filename) } + }, + exists: { _, _ in true } + ) + } + + private func appearances() -> [Repository.ID: RepositoryAppearance] { + @Shared(.repositoryAppearances) var appearances + return appearances + } + + // MARK: - Spawn eligibility + + @Test func newRepositoryAfterInitialLoadIsScannedAndCommitted() async { + let newRepo = makeRepository(id: "/tmp/new", name: "new", kind: .plain) + let scanned = LockIsolated<[URL]>([]) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { root in + scanned.withValue { $0.append(root) } + return RepositoryIconCandidate( + imageURL: root.appending(path: "logo.png"), + evidence: .webAsset + ) + } + $0.repositoryIconAssetStore = recordingAssetStore() + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [newRepo], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [newRepo.rootURL] + ) + ) + ) + await store.receive(\.repositoryManagement.repositoryIconDetected) + await store.finish() + + #expect(scanned.value == [newRepo.rootURL]) + #expect(appearances()["/tmp/new"]?.icon == .detectedImage(filename: "detected.png")) + } + + @Test func existingRepositoriesAreNotRescanned() async { + let existing = makeRepository(id: "/tmp/existing", name: "existing") + let scanned = LockIsolated<[URL]>([]) + let store = TestStore(initialState: makeState(repositories: [existing])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { root in + scanned.withValue { $0.append(root) } + return nil + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [existing], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [existing.rootURL] + ) + ) + ) + await store.finish() + + #expect(scanned.value.isEmpty) + } + + @Test func disabledSettingPreventsScan() async { + let newRepo = makeRepository(id: "/tmp/new", name: "new", kind: .plain) + let scanned = LockIsolated<[URL]>([]) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { root in + scanned.withValue { $0.append(root) } + return nil + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.detectRepositoryIconsAutomatically = false } + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [newRepo], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [newRepo.rootURL] + ) + ) + ) + await store.finish() + + #expect(scanned.value.isEmpty) + } + + @Test func suppressedOrAlreadyIconedRepositoriesAreSkipped() async { + let suppressed = makeRepository(id: "/tmp/suppressed", name: "suppressed", kind: .plain) + let iconed = makeRepository(id: "/tmp/iconed", name: "iconed", kind: .plain) + let fresh = makeRepository(id: "/tmp/fresh", name: "fresh", kind: .plain) + let scanned = LockIsolated<[URL]>([]) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { root in + scanned.withValue { $0.append(root) } + return nil + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/suppressed"] = RepositoryAppearance(iconDetectionSuppressed: true) + $0["/tmp/iconed"] = RepositoryAppearance(icon: .sfSymbol("folder")) + } + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [suppressed, iconed, fresh], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [suppressed.rootURL, iconed.rootURL, fresh.rootURL] + ) + ) + ) + await store.finish() + + #expect(scanned.value == [fresh.rootURL]) + } + + // MARK: - Commit guards + + @Test func detectionResultCommitsForUntouchedRepository() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore() + } + + await store.send( + .repositoryManagement(.repositoryIconDetected("/tmp/repo", filename: "detected.png")) + ) + await store.finish() + + #expect(appearances()["/tmp/repo"]?.icon == .detectedImage(filename: "detected.png")) + } + + @Test func manualIconSetDuringScanDiscardsResult() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let removed = LockIsolated<[String]>([]) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore(removed: removed) + } + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/repo"] = RepositoryAppearance(icon: .sfSymbol("hammer")) + } + + await store.send( + .repositoryManagement(.repositoryIconDetected("/tmp/repo", filename: "detected.png")) + ) + await store.finish() + + #expect(removed.value == ["detected.png"]) + #expect(appearances()["/tmp/repo"]?.icon == .sfSymbol("hammer")) + } + + @Test func explicitClearDuringScanDiscardsResult() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let removed = LockIsolated<[String]>([]) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore(removed: removed) + } + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/repo"] = RepositoryAppearance(iconDetectionSuppressed: true) + } + + await store.send( + .repositoryManagement(.repositoryIconDetected("/tmp/repo", filename: "detected.png")) + ) + await store.finish() + + #expect(removed.value == ["detected.png"]) + #expect(appearances()["/tmp/repo"]?.icon == nil) + } + + @Test func removedRepositoryDiscardsResult() async { + let removed = LockIsolated<[String]>([]) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore(removed: removed) + } + + await store.send( + .repositoryManagement(.repositoryIconDetected("/tmp/gone", filename: "detected.png")) + ) + await store.finish() + + #expect(removed.value == ["detected.png"]) + #expect(appearances()["/tmp/gone"] == nil) + } + + @Test func disabledSettingAtCommitTimeDiscardsResult() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let removed = LockIsolated<[String]>([]) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore(removed: removed) + } + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.detectRepositoryIconsAutomatically = false } + + await store.send( + .repositoryManagement(.repositoryIconDetected("/tmp/repo", filename: "detected.png")) + ) + await store.finish() + + #expect(removed.value == ["detected.png"]) + #expect(appearances()["/tmp/repo"] == nil) + } + + @Test func commitPreservesExistingColor() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore() + } + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/repo"] = RepositoryAppearance(icon: nil, color: .blue) + } + + await store.send( + .repositoryManagement(.repositoryIconDetected("/tmp/repo", filename: "detected.png")) + ) + await store.finish() + + #expect(appearances()["/tmp/repo"]?.icon == .detectedImage(filename: "detected.png")) + #expect(appearances()["/tmp/repo"]?.color == .blue) + } + + // MARK: - Removal cleanup + + @Test func repositoryRemovalCleansDetectedIconAndSuppressionButKeepsManualAppearance() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let removed = LockIsolated<[String]>([]) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore(removed: removed) + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + } + store.exhaustivity = .off + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/repo"] = RepositoryAppearance( + icon: .detectedImage(filename: "detected.png"), + color: .blue + ) + } + + await store.send( + .repositoryManagement(.repositoryRemoved("/tmp/repo", selectionWasRemoved: false)) + ) + await store.finish() + + #expect(removed.value == ["detected.png"]) + #expect(appearances()["/tmp/repo"]?.icon == nil) + #expect(appearances()["/tmp/repo"]?.color == .blue) + #expect(appearances()["/tmp/repo"]?.iconDetectionSuppressed == false) + } + + @Test func repositoryRemovalClearsSuppressionOnlyEntryEntirely() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore() + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + } + store.exhaustivity = .off + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/repo"] = RepositoryAppearance(iconDetectionSuppressed: true) + } + + await store.send( + .repositoryManagement(.repositoryRemoved("/tmp/repo", selectionWasRemoved: false)) + ) + await store.finish() + + #expect(appearances()["/tmp/repo"] == nil) + } + + @Test func repositoryRemovalKeepsManualUserImageUntouched() async { + let repo = makeRepository(id: "/tmp/repo", name: "repo", kind: .plain) + let removed = LockIsolated<[String]>([]) + let store = TestStore(initialState: makeState(repositories: [repo])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconAssetStore = recordingAssetStore(removed: removed) + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + } + store.exhaustivity = .off + @Shared(.repositoryAppearances) var shared + $shared.withLock { + $0["/tmp/repo"] = RepositoryAppearance(icon: .userImage(filename: "mine.png")) + } + + await store.send( + .repositoryManagement(.repositoryRemoved("/tmp/repo", selectionWasRemoved: false)) + ) + await store.finish() + + #expect(removed.value.isEmpty) + #expect(appearances()["/tmp/repo"]?.icon == .userImage(filename: "mine.png")) + } + + // MARK: - Workspace exclusion + + @Test func workspaceContainersAreNeverScanned() async { + let workspace = ProjectWorkspace(title: "WS") + let container = makeRepository( + id: "/tmp/workspace", + name: "workspace", + kind: .plain, + workspace: workspace + ) + let scanned = LockIsolated<[URL]>([]) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { root in + scanned.withValue { $0.append(root) } + return nil + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [container], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [container.rootURL] + ) + ) + ) + await store.finish() + + #expect(scanned.value.isEmpty) + } +} diff --git a/supacodeTests/RepositoryAppearanceTests.swift b/supacodeTests/RepositoryAppearanceTests.swift index b4436cb2..b0a2ec7b 100644 --- a/supacodeTests/RepositoryAppearanceTests.swift +++ b/supacodeTests/RepositoryAppearanceTests.swift @@ -71,4 +71,39 @@ struct RepositoryAppearanceTests { #expect(decoded.icon == .sfSymbol("folder")) #expect(decoded.color == .red) } + + // MARK: - Detection suppression + + @Test func legacyPayloadDecodesWithSuppressionOff() throws { + let raw = Data(#"{"icon": "folder"}"#.utf8) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: raw) + #expect(!decoded.iconDetectionSuppressed) + } + + @Test func suppressionRoundTrips() throws { + let original = RepositoryAppearance(icon: nil, color: nil, iconDetectionSuppressed: true) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) + #expect(decoded == original) + } + + @Test func suppressionKeyIsOmittedWhenFalse() throws { + let data = try JSONEncoder().encode(RepositoryAppearance(icon: .sfSymbol("folder"))) + let json = try #require(String(bytes: data, encoding: .utf8)) + #expect(!json.contains("iconDetectionSuppressed")) + } + + @Test func suppressionAloneIsNotEmpty() { + // Pruning an entry that only carries suppression would let an + // in-flight detection commit after an explicit clear. + let appearance = RepositoryAppearance(icon: nil, color: nil, iconDetectionSuppressed: true) + #expect(!appearance.isEmpty) + } + + @Test func codableRoundTripDetectedIcon() throws { + let original = RepositoryAppearance(icon: .detectedImage(filename: "abc.png"), color: nil) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) + #expect(decoded == original) + } } diff --git a/supacodeTests/RepositoryIconDetectorTests.swift b/supacodeTests/RepositoryIconDetectorTests.swift new file mode 100644 index 00000000..61360553 --- /dev/null +++ b/supacodeTests/RepositoryIconDetectorTests.swift @@ -0,0 +1,369 @@ +import AppKit +import Foundation +import Testing + +@testable import supacode + +struct RepositoryIconDetectorTests { + // MARK: - Fixtures + + private func pngData(width: Int, height: Int) -> Data { + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) + guard let bitmap, let data = bitmap.representation(using: .png, properties: [:]) else { + Issue.record("Could not create PNG fixture") + return Data() + } + return data + } + + private var svgData: Data { + let svg = + #""# + + #""# + return Data(svg.utf8) + } + + private struct ManifestEntry { + var filename: String + var size: String + var scale: String? + } + + private func appIconSetManifest(_ entries: [ManifestEntry]) -> Data { + let images = entries.map { entry in + var image = ["filename": entry.filename, "size": entry.size, "idiom": "mac"] + if let scale = entry.scale { + image["scale"] = scale + } + return image + } + guard let data = try? JSONSerialization.data(withJSONObject: ["images": images]) else { + Issue.record("Could not encode manifest fixture") + return Data() + } + return data + } + + private let flutterPubspec = Data("name: demo\ndependencies:\n flutter:\n sdk: flutter\n".utf8) + private let reactNativePackageJSON = Data( + #"{"name":"demo","dependencies":{"react":"19.0.0","react-native":"0.80.0"}}"#.utf8 + ) + + // MARK: - Apple + + @Test func appleCatalogPicksLargestReferencedRaster() throws { + try withTemporaryProjectDirectory( + entries: ["App.xcodeproj/"], + contents: [ + "App/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "small.png", size: "16x16", scale: "1x"), + .init(filename: "large.png", size: "512x512", scale: "2x"), + ]), + "App/Assets.xcassets/AppIcon.appiconset/small.png": pngData(width: 16, height: 16), + "App/Assets.xcassets/AppIcon.appiconset/large.png": pngData(width: 1024, height: 1024), + ] + ) { root in + let candidate = RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .appleAssetCatalog) + #expect(candidate?.imageURL.lastPathComponent == "large.png") + } + } + + @Test func appleCatalogFallsBackWhenLargestEntryFileIsMissing() throws { + try withTemporaryProjectDirectory( + entries: ["Package.swift"], + contents: [ + "Sources/App/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "missing.png", size: "512x512", scale: "2x"), + .init(filename: "present.png", size: "128x128", scale: "1x"), + ]), + "Sources/App/Assets.xcassets/AppIcon.appiconset/present.png": pngData(width: 128, height: 128), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "present.png") + } + } + + @Test func appleCatalogWithMalformedManifestYieldsNothing() throws { + try withTemporaryProjectDirectory( + entries: ["App.xcodeproj/"], + contents: [ + "Assets.xcassets/AppIcon.appiconset/Contents.json": Data("not json".utf8), + "Assets.xcassets/AppIcon.appiconset/icon.png": pngData(width: 64, height: 64), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func appleCatalogInsideDependencyDirectoryIsIgnored() throws { + try withTemporaryProjectDirectory( + entries: ["App.xcodeproj/"], + contents: [ + "node_modules/pkg/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "icon.png", size: "64x64", scale: "1x") + ]), + "node_modules/pkg/Assets.xcassets/AppIcon.appiconset/icon.png": pngData(width: 64, height: 64), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func nearerAppleCatalogWinsOverDeeperOne() throws { + try withTemporaryProjectDirectory( + entries: ["App.xcworkspace/"], + contents: [ + "Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "near.png", size: "64x64", scale: "1x") + ]), + "Assets.xcassets/AppIcon.appiconset/near.png": pngData(width: 64, height: 64), + "Modules/Deep/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "deep.png", size: "1024x1024", scale: "1x") + ]), + "Modules/Deep/Assets.xcassets/AppIcon.appiconset/deep.png": pngData(width: 1024, height: 1024), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "near.png") + } + } + + // MARK: - Android + + @Test func androidLauncherPrefersHighestDensity() throws { + try withTemporaryProjectDirectory( + entries: ["gradlew", "settings.gradle"], + contents: [ + "app/src/main/res/mipmap-mdpi/ic_launcher.png": pngData(width: 48, height: 48), + "app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": pngData(width: 192, height: 192), + ] + ) { root in + let candidate = RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .androidLauncher) + #expect(candidate?.imageURL.path(percentEncoded: false).contains("mipmap-xxxhdpi") == true) + } + } + + @Test func androidAdaptiveOnlyProjectYieldsNothing() throws { + try withTemporaryProjectDirectory( + entries: ["gradlew"], + contents: [ + "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml": Data("".utf8) + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + // MARK: - Flutter / React Native + + @Test func flutterPrefersIOSRunnerCatalogOverAndroidLauncher() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "pubspec.yaml": flutterPubspec, + "ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "Icon-App-1024x1024@1x.png", size: "1024x1024", scale: "1x") + ]), + "ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png": + pngData(width: 1024, height: 1024), + "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": pngData(width: 192, height: 192), + ] + ) { root in + let candidate = RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .appleAssetCatalog) + #expect(candidate?.imageURL.path(percentEncoded: false).contains("ios/Runner") == true) + } + } + + @Test func flutterFallsBackToAndroidLauncher() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "pubspec.yaml": flutterPubspec, + "android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": pngData(width: 96, height: 96), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root)?.evidence == .androidLauncher) + } + } + + @Test func pureDartPackageWithoutFlutterYieldsNothing() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "pubspec.yaml": Data("name: pure_dart\nenvironment:\n sdk: ^3.0.0\n".utf8), + "android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": pngData(width: 96, height: 96), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func reactNativeUsesIOSCatalogThenAndroid() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": reactNativePackageJSON, + "ios/Demo/Images.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "AppIcon.png", size: "1024x1024") + ]), + "ios/Demo/Images.xcassets/AppIcon.appiconset/AppIcon.png": pngData(width: 1024, height: 1024), + ] + ) { root in + let candidate = RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .appleAssetCatalog) + } + } + + // MARK: - Web + + @Test func webManifestIconWinsOverFavicon() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "public/manifest.json": Data( + #"{"icons":[{"src":"icons/icon-192.png","sizes":"192x192"},{"src":"icons/icon-512.png","sizes":"512x512"}]}"# + .utf8 + ), + "public/icons/icon-192.png": pngData(width: 192, height: 192), + "public/icons/icon-512.png": pngData(width: 512, height: 512), + "public/favicon.png": pngData(width: 32, height: 32), + ] + ) { root in + let candidate = RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .webAsset) + #expect(candidate?.imageURL.lastPathComponent == "icon-512.png") + } + } + + @Test func htmlRelIconIsResolvedAgainstRoot() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "index.html": Data( + #""#.utf8 + ), + "assets/fav.png": pngData(width: 64, height: 64), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "fav.png") + } + } + + @Test func staticFolderWithIndexHTMLAndFaviconQualifies() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "index.html": Data("".utf8), + "favicon.svg": svgData, + ] + ) { root in + let candidate = RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .webAsset) + #expect(candidate?.imageURL.lastPathComponent == "favicon.svg") + } + } + + @Test func rootLogoIsLastResortForWebProjects() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "logo.svg": svgData, + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "logo.svg") + } + } + + @Test func nonWebProjectIgnoresRootLogo() throws { + try withTemporaryProjectDirectory( + entries: ["go.mod"], + contents: [ + "logo.svg": svgData + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func remoteAndDataIconReferencesAreRejected() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "index.html": Data( + #""#.utf8 + ), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + // MARK: - Validation + + @Test func undecodableImageIsRejected() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "logo.png": Data("this is not a png".utf8), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func oversizedRasterIsRejected() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "logo.png": pngData(width: 5000, height: 16), + ] + ) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func symlinkEscapingTheRepositoryIsRejected() throws { + let fileManager = FileManager.default + let outside = fileManager.temporaryDirectory + .appending(path: "outside-\(UUID().uuidString).png") + try pngData(width: 64, height: 64).write(to: outside) + defer { try? fileManager.removeItem(at: outside) } + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8) + ] + ) { root in + try fileManager.createSymbolicLink( + at: root.appending(path: "logo.png"), + withDestinationURL: outside + ) + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func emptyRepositoryYieldsNothing() throws { + try withTemporaryProjectDirectory(entries: ["README.md"]) { root in + #expect(RepositoryIconDetector.detect(at: root) == nil) + } + } +} diff --git a/supacodeTests/RepositoryIconSourceTests.swift b/supacodeTests/RepositoryIconSourceTests.swift index a732dd91..7cb4efb3 100644 --- a/supacodeTests/RepositoryIconSourceTests.swift +++ b/supacodeTests/RepositoryIconSourceTests.swift @@ -114,4 +114,37 @@ struct RepositoryIconSourceTests { @Test func svgUserImageWithUppercaseExtensionIsTintable() { #expect(RepositoryIconSource.userImage(filename: "abc.SVG").isTintable) } + + // MARK: - detectedImage + + @Test func detectedImageUsesDetectedMarker() { + let icon = RepositoryIconSource.detectedImage(filename: "abc.png") + #expect(icon.storageString == "@detected:abc.png") + } + + @Test func parseDetectedMarker() { + #expect( + RepositoryIconSource.parse("@detected:abc.svg") == .detectedImage(filename: "abc.svg") + ) + } + + @Test func detectedImageRoundTrip() { + let source = RepositoryIconSource.detectedImage(filename: "abc-123.webp") + #expect(RepositoryIconSource.parse(source.storageString) == source) + } + + @Test func detectedImageIsNeverTintable() { + // A detected SVG keeps its intrinsic colors — unlike a user SVG. + #expect(!RepositoryIconSource.detectedImage(filename: "abc.svg").isTintable) + #expect(!RepositoryIconSource.detectedImage(filename: "abc.png").isTintable) + } + + // MARK: - storedImageFilename + + @Test func storedImageFilenameCoversFileBackedCases() { + #expect(RepositoryIconSource.userImage(filename: "a.png").storedImageFilename == "a.png") + #expect(RepositoryIconSource.detectedImage(filename: "b.svg").storedImageFilename == "b.svg") + #expect(RepositoryIconSource.sfSymbol("folder").storedImageFilename == nil) + #expect(RepositoryIconSource.bundledAsset("Docker").storedImageFilename == nil) + } } diff --git a/supacodeTests/RepositorySettingsAppearanceTests.swift b/supacodeTests/RepositorySettingsAppearanceTests.swift index 9d1998c4..26970b63 100644 --- a/supacodeTests/RepositorySettingsAppearanceTests.swift +++ b/supacodeTests/RepositorySettingsAppearanceTests.swift @@ -103,6 +103,7 @@ struct RepositorySettingsAppearanceTests { await store.send(.setAppearanceIcon(nil)) { $0.appearance.icon = nil + $0.appearance.iconDetectionSuppressed = true } await store.finish() @@ -110,6 +111,49 @@ struct RepositorySettingsAppearanceTests { #expect(removed.value.first?.0 == "old.png") } + @Test func clearingDetectedIconRecordsSuppressionAndRemovesFile() async throws { + let removed = LockIsolated<[(String, URL)]>([]) + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") + let settingsStorage = SettingsTestStorage() + let store = makeStore( + initialAppearance: RepositoryAppearance( + icon: .detectedImage(filename: "detected.png"), color: nil + ), + iconAssetStore: .testRecording(removed: removed), + appearancesURL: appearancesURL, + settingsStorage: settingsStorage + ) + + await store.send(.setAppearanceIcon(nil)) { + $0.appearance.icon = nil + $0.appearance.iconDetectionSuppressed = true + } + await store.finish() + + #expect(removed.value.first?.0 == "detected.png") + // Suppression must survive persistence so a pending detection + // can't restore the icon the user just cleared. + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) + #expect(persisted["repo-1"]?.iconDetectionSuppressed == true) + } + + @Test func replacingDetectedIconWithManualOneRemovesDetectedFile() async throws { + let removed = LockIsolated<[(String, URL)]>([]) + let store = makeStore( + initialAppearance: RepositoryAppearance( + icon: .detectedImage(filename: "detected.png"), color: nil + ), + iconAssetStore: .testRecording(removed: removed) + ) + + await store.send(.setAppearanceIcon(.sfSymbol("folder"))) { + $0.appearance.icon = .sfSymbol("folder") + } + await store.finish() + + #expect(removed.value.first?.0 == "detected.png") + } + @Test func replacingUserImageRemovesPreviousFile() async throws { let removed = LockIsolated<[(String, URL)]>([]) let store = makeStore( @@ -223,11 +267,32 @@ struct RepositorySettingsAppearanceTests { ) await store.send(.resetAppearance) { - $0.appearance = .empty + // Reset removed an icon, so it records suppression like Clear. + $0.appearance = RepositoryAppearance(iconDetectionSuppressed: true) } await store.finish() #expect(removed.value.first?.0 == "abc.png") + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) + #expect(persisted["repo-1"]?.iconDetectionSuppressed == true) + #expect(persisted["repo-1"]?.icon == nil) + #expect(persisted["repo-1"]?.color == nil) + } + + @Test func resetAppearanceWithColorOnlyDoesNotSuppressDetection() async throws { + let appearancesURL = URL(fileURLWithPath: "/tmp/appearances-\(UUID().uuidString).json") + let settingsStorage = SettingsTestStorage() + let store = makeStore( + initialAppearance: RepositoryAppearance(icon: nil, color: .blue), + appearancesURL: appearancesURL, + settingsStorage: settingsStorage + ) + + await store.send(.resetAppearance) { + $0.appearance = .empty + } + await store.finish() + let persisted = readAppearances(at: appearancesURL, storage: settingsStorage) #expect(persisted["repo-1"] == nil) } diff --git a/supacodeTests/RepositorySettingsSuggestionsTests.swift b/supacodeTests/RepositorySettingsSuggestionsTests.swift new file mode 100644 index 00000000..8b65dc23 --- /dev/null +++ b/supacodeTests/RepositorySettingsSuggestionsTests.swift @@ -0,0 +1,248 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct RepositorySettingsSuggestionsTests { + private let fixture = RepositorySymbolSuggestions( + primary: "cat", + alternates: ["dog", "bird", "fish", "hare"], + reason: "Directly depicts the project's mascot.", + source: .readme, + usedAI: true + ) + + private func makeStore( + client: RepositorySymbolSuggestionClient + ) -> TestStore { + TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: URL(fileURLWithPath: "/tmp/repo-1"), + repositoryID: "repo-1", + repositoryKind: .plain, + settings: .default, + userSettings: .default + ) + ) { + RepositorySettingsFeature() + } withDependencies: { + $0.repositorySymbolSuggestionClient = client + } + } + + @Test func suggestIconGeneratesWhenNothingIsCached() async { + let generateCalls = LockIsolated(0) + let fixture = fixture + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in + generateCalls.withValue { $0 += 1 } + return fixture + } + ) + ) + + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + $0.symbolSuggestions = .loading + } + await store.receive(\.symbolSuggestionsLoaded) { + $0.symbolSuggestions = .loaded(fixture) + } + await store.finish() + + #expect(generateCalls.value == 1) + } + + @Test func suggestIconResolvesFromCacheWithoutGenerating() async { + let generateCalls = LockIsolated(0) + let fixture = fixture + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in fixture }, + generateSuggestions: { _, _, _ in + generateCalls.withValue { $0 += 1 } + return fixture + } + ) + ) + + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + $0.symbolSuggestions = .loading + } + await store.receive(\.symbolSuggestionsLoaded) { + $0.symbolSuggestions = .loaded(fixture) + } + await store.finish() + + #expect(generateCalls.value == 0) + } + + @Test func chooseSymbolStaysIdleWithoutCache() async { + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in + Issue.record("Choose Symbol must never trigger generation") + throw CancellationError() + } + ) + ) + + await store.send(.chooseSymbolTapped) { + $0.isSymbolPickerPresented = true + } + await store.finish() + } + + @Test func chooseSymbolSurfacesCachedRun() async { + let fixture = fixture + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in fixture }, + generateSuggestions: { _, _, _ in + Issue.record("Choose Symbol must never trigger generation") + throw CancellationError() + } + ) + ) + + await store.send(.chooseSymbolTapped) { + $0.isSymbolPickerPresented = true + } + await store.receive(\.symbolSuggestionsLoaded) { + $0.symbolSuggestions = .loaded(fixture) + } + await store.finish() + } + + @Test func regenerateBypassesCache() async { + let generateCalls = LockIsolated(0) + let fixture = fixture + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in fixture }, + generateSuggestions: { _, _, _ in + generateCalls.withValue { $0 += 1 } + return fixture + } + ) + ) + + await store.send(.regenerateSuggestionsTapped) { + $0.symbolSuggestions = .loading + } + await store.receive(\.symbolSuggestionsLoaded) { + $0.symbolSuggestions = .loaded(fixture) + } + await store.finish() + + #expect(generateCalls.value == 1) + } + + @Test func dismissingSheetCancelsInFlightGeneration() async { + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in + // Parks until the surrounding task is cancelled, mirroring a + // long model call that honors cooperative cancellation. + let (stream, _) = AsyncStream.makeStream() + for await _ in stream {} + throw CancellationError() + } + ) + ) + + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + $0.symbolSuggestions = .loading + } + await store.send(.symbolPickerDismissed) { + $0.isSymbolPickerPresented = false + $0.symbolSuggestions = .idle + } + await store.finish() + } + + @Test func dismissingSheetKeepsFinishedResults() async { + let fixture = fixture + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in fixture } + ) + ) + + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + $0.symbolSuggestions = .loading + } + await store.receive(\.symbolSuggestionsLoaded) { + $0.symbolSuggestions = .loaded(fixture) + } + await store.send(.symbolPickerDismissed) { + $0.isSymbolPickerPresented = false + } + await store.finish() + } + + @Test func generationFailureIsSurfaced() async { + struct Boom: LocalizedError { + var errorDescription: String? { "boom" } + } + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in throw Boom() } + ) + ) + + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + $0.symbolSuggestions = .loading + } + await store.receive(\.symbolSuggestionsFailed) { + $0.symbolSuggestions = .failed("boom") + } + await store.finish() + } + + @Test func suggestWhileLoadedShowsExistingResultsWithoutNewRun() async { + let generateCalls = LockIsolated(0) + let fixture = fixture + let store = makeStore( + client: RepositorySymbolSuggestionClient( + cachedSuggestions: { _ in nil }, + generateSuggestions: { _, _, _ in + generateCalls.withValue { $0 += 1 } + return fixture + } + ) + ) + + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + $0.symbolSuggestions = .loading + } + await store.receive(\.symbolSuggestionsLoaded) { + $0.symbolSuggestions = .loaded(fixture) + } + await store.send(.symbolPickerDismissed) { + $0.isSymbolPickerPresented = false + } + // Re-invoking Suggest an Icon shows the finished run; Regenerate is + // the explicit path to a fresh one. + await store.send(.suggestIconTapped) { + $0.isSymbolPickerPresented = true + } + await store.finish() + + #expect(generateCalls.value == 1) + } +} diff --git a/supacodeTests/RepositorySuggestionInputTests.swift b/supacodeTests/RepositorySuggestionInputTests.swift new file mode 100644 index 00000000..7e052daa --- /dev/null +++ b/supacodeTests/RepositorySuggestionInputTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing + +@testable import supacode + +struct RepositorySuggestionInputTests { + // MARK: - Markdown cleaning + + @Test func stripsFrontMatterBadgesCodeAndMarkup() { + let markdown = """ + --- + title: Demo + --- + # Kingfisher + + ![badge](https://img.shields.io/build.svg) ![badge2](https://example.com/b2.svg) + + A lightweight, pure-Swift library for downloading and caching images from the web. + + ```swift + let code = "should disappear" + ``` + + See the [documentation](https://example.com/docs) for details. + """ + let cleaned = RepositorySuggestionInput.cleanedMarkdownSynopsis(markdown) + #expect(!cleaned.contains("title: Demo")) + #expect(!cleaned.contains("shields.io")) + #expect(!cleaned.contains("should disappear")) + #expect(!cleaned.contains("#")) + #expect(!cleaned.contains("https://example.com/docs")) + #expect(cleaned.contains("Kingfisher")) + #expect(cleaned.contains("pure-Swift library for downloading and caching images")) + #expect(cleaned.contains("documentation")) + } + + @Test func capsAtSixHundredCharacters() { + let long = String(repeating: "词", count: 2000) + let cleaned = RepositorySuggestionInput.cleanedMarkdownSynopsis(long) + #expect(cleaned.count == RepositorySuggestionInput.maxLength) + } + + @Test func stripsHTMLTagsAndComments() { + let markdown = """ +

+ + A terminal orchestrator for coding agents. + """ + let cleaned = RepositorySuggestionInput.cleanedMarkdownSynopsis(markdown) + #expect(!cleaned.contains(" Void ) throws { let fileManager = FileManager.default @@ -22,5 +23,13 @@ func withTemporaryProjectDirectory( try Data().write(to: directory.appending(path: entry)) } } + for (path, data) in contents { + let fileURL = directory.appending(path: path) + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: fileURL) + } try body(directory) } diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift index 19e4e91f..21884577 100644 --- a/supacodeTests/WorktreeProjectKindTests.swift +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -51,6 +51,84 @@ struct WorktreeProjectKindTests { } } + // MARK: - Flutter + + @Test func detectsFlutterFromPubspecWithFlutterKey() throws { + try withTemporaryProjectDirectory( + entries: ["ios/", "android/"], + contents: [ + "pubspec.yaml": Data("name: demo\ndependencies:\n flutter:\n sdk: flutter\n".utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .flutter) + } + } + + @Test func pureDartPubspecIsNotFlutter() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "pubspec.yaml": Data("name: pure\nenvironment:\n sdk: ^3.0.0\n".utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == nil) + } + } + + @Test func flutterWinsOverNativeShellMarkers() throws { + try withTemporaryProjectDirectory( + entries: ["App.xcworkspace/", "gradlew"], + contents: [ + "pubspec.yaml": Data("name: demo\nflutter:\n uses-material-design: true\n".utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .flutter) + } + } + + // MARK: - React Native + + @Test func detectsReactNativeFromDependencyAndNativeShell() throws { + try withTemporaryProjectDirectory( + entries: ["android/"], + contents: [ + "package.json": Data(#"{"dependencies":{"react-native":"0.80.0"}}"#.utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .reactNative) + } + } + + @Test func reactNativeDependencyWithoutNativeShellIsWeb() throws { + try withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"dependencies":{"react-native":"0.80.0"}}"#.utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .web) + } + } + + @Test func plainPackageJSONWithNativeFoldersIsWeb() throws { + try withTemporaryProjectDirectory( + entries: ["ios/"], + contents: [ + "package.json": Data(#"{"dependencies":{"react":"19.0.0"}}"#.utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .web) + } + } + + @Test func hybridKindsPreferTheirDocumentedEditors() { + #expect(WorktreeProjectKind.flutter.preferredActions.first == .androidStudio) + #expect(WorktreeProjectKind.flutter.preferredActions.contains(.vscode)) + #expect(WorktreeProjectKind.reactNative.preferredActions.first == .cursor) + #expect(WorktreeProjectKind.reactNative.preferredActions.contains(.webstorm)) + #expect(WorktreeProjectKind.reactNative.preferredActions.contains(.androidStudio)) + } + @Test func returnsNilForMissingDirectory() { let directory = FileManager.default.temporaryDirectory .appending(path: "missing-\(UUID().uuidString)") -- 2.51.2 From 1d11cf2fcb90198119ce0e1aeae273130160ea00 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 25 Jul 2026 21:17:40 +0900 Subject: [PATCH 3/7] Add Unity kind, Icon Composer, Tauri, and generic icon probes Unity projects (root or one folder down, proven by ProjectVersion.txt) become a first-class kind preferring Rider, so SDK-style repos stop misclassifying as Ruby or .NET. Icon detection gains Icon Composer .icon bundles flattened through the QuickLook thumbnail pipeline, Tauri bundle icons from tauri.conf.json, explicit package.json icon declarations, and a last generic tier that accepts a near-square icon/logo asset at conventional locations, with an aspect-ratio gate rejecting wordmark logos. Claude-Session: https://claude.ai/code/session_01FzjRrTgza7QTwmaLY258rv --- .../051-repository-icon-detection/000-plan.md | 6 + .../001-action.md | 35 ++ docs/components/repositories-and-worktrees.md | 20 +- .../RepositoryIconComposerRenderer.swift | 40 +++ .../Repositories/RepositoryIconDetector.swift | 171 +++++++++- .../RepositoryIconDetectorClient.swift | 2 +- .../RepositoryIconDetectorScanner.swift | 67 +++- supacode/Domain/WorktreeProjectKind.swift | 40 +++ .../RepositoryIconDetectorTests.swift | 298 ++++++++++++++---- supacodeTests/TemporaryProjectDirectory.swift | 22 +- supacodeTests/WorktreeProjectKindTests.swift | 39 +++ 11 files changed, 640 insertions(+), 100 deletions(-) create mode 100644 supacode/Clients/Repositories/RepositoryIconComposerRenderer.swift diff --git a/docs-ai/051-repository-icon-detection/000-plan.md b/docs-ai/051-repository-icon-detection/000-plan.md index 2c44f710..5b727ae1 100644 --- a/docs-ai/051-repository-icon-detection/000-plan.md +++ b/docs-ai/051-repository-icon-detection/000-plan.md @@ -182,3 +182,9 @@ redistribution terms before publishing any bundled metadata as open source. sheet with an in-sheet Suggest button), suggestion caching (session-scoped in-memory cache with Regenerate), and delivery sequencing (extract the shared recommender package first, then ship automatic detection and the suggestion flow together). +- 2026-07-25 (follow-up): onevcat approved additional probes — Icon Composer `.icon` + bundles, Tauri bundle icons, `package.json` `"icon"`, a Unity project kind for Open In, + and a generic near-square `icon`/`logo` fallback tier. The generic tier supersedes this + plan's "defer root logo" stance; the aspect-ratio gate addresses the precision concern + that motivated the deferral. See the follow-up section in + [001-action.md](001-action.md). diff --git a/docs-ai/051-repository-icon-detection/001-action.md b/docs-ai/051-repository-icon-detection/001-action.md index 9646d5dc..e98930ee 100644 --- a/docs-ai/051-repository-icon-detection/001-action.md +++ b/docs-ai/051-repository-icon-detection/001-action.md @@ -125,6 +125,41 @@ their documented editor preferences (Flutter: Android Studio → IntelliJ 0.844 meanPickScore), and Prowl's reducer tests treat the client as a fixture boundary. +## Follow-up (same day): more kinds and a generic tier + +Requested by onevcat after the initial implementation: + +- **Unity** became a `WorktreeProjectKind` for Automatic Open In + (Rider → VS Code family). The signal is + `ProjectSettings/ProjectVersion.txt` at the root **or one folder + down** — SDK-style repos (e.g. UniWebView) keep the Unity project + beside tooling manifests whose markers (Gemfile, root `.sln` files + Unity generates) would otherwise win. No icon probe: Unity serializes + its icon inside `ProjectSettings.asset`, which has no extractable + image with acceptable confidence. +- **Icon Composer `.icon` bundles** are now the first Apple probe. The + format is layered (background fill + glass layers + per-appearance + variants), so single-layer extraction would misrepresent it; instead + the bundle is flattened by the system QuickLook thumbnail pipeline + (`.thumbnail` representation only — machines without the QL support + fall through to the asset-catalog probe). Renderer is injected so + tests stay off the system pipeline. +- **Tauri**: `src-tauri/tauri.conf.json` (v1 and v2 shapes) declares + its bundle icons explicitly; the conventional flat `icon.png` is + preferred. Detector-only — Tauri's best editor is the VS Code family, + which the generic Open In fallback already reaches. +- **`package.json` `"icon"`** (VS Code extensions and friends) became + step 0 of the web probe — an explicit declaration outranking every + convention-based source. +- **Generic fallback tier** (product decision, superseding the plan's + "defer root logo.*" stance): when no kind probe yields a candidate, + accept `appicon`/`app-icon`/`icon`/`logo` × `svg`/`png`/`webp` at the + root, `assets/`, or `.github/` — gated by a **near-square aspect + check (≤ 1.5:1)** that rejects wordmark logos and social banners, + which was the original precision concern behind deferring this tier. +- `RepositoryIconDetector.detect` became `async` (QuickLook render); + the client surface was already async, so only tests changed shape. + ## Deviations from the plan - No pending-scan bookkeeping in `State`: cancellation uses a shared diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index b0609e3f..5150b8e9 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -159,6 +159,9 @@ prefers an app matching the project type: Flutter (`pubspec.yaml` with a `flutter:` key) → Android Studio (then IntelliJ, then the VS Code family), React Native (`package.json` depending on `react-native` plus an `ios/` or `android/` folder) → VS Code family (then WebStorm, then Android Studio), +Unity (`ProjectSettings/ProjectVersion.txt` at the root or one folder down, +covering SDK repos that keep the Unity project beside tooling manifests) → +Rider (then the VS Code family), `.xcodeproj`/`.xcworkspace`/ `Package.swift`/`Project.swift` → Xcode, Gradle files → Android Studio (then IntelliJ IDEA, then IDEA EAP), `*.sln`/`*.csproj` → Rider, `pom.xml` → @@ -195,12 +198,17 @@ a custom color). The color tints the icon, the name, the Shelf spine (if **Automatic icon detection** (`detectRepositoryIconsAutomatically`, default on): when a repository or folder is newly added, Prowl scans it locally in the -background for a high-confidence product icon — an Apple `AppIcon.appiconset` -raster, an Android launcher raster, the iOS/Android assets of a Flutter or -React Native project, or a web manifest icon / `rel=icon` favicon / root -logo for web projects — and silently sets it as the repo icon. Detection -never runs for existing repositories, workspaces, or repos that already have -an icon, and it never replaces a manual choice. **Clear Icon** removes a +background for a high-confidence product icon — an Icon Composer `.icon` +bundle (flattened via QuickLook) or `AppIcon.appiconset` raster for Apple +projects, an Android launcher raster, the iOS/Android assets of a Flutter or +React Native project, Tauri bundle icons from `src-tauri/tauri.conf.json`, a +`package.json` `"icon"` declaration, or a web manifest icon / `rel=icon` +favicon / root logo for web projects — and silently sets it as the repo icon. +For any other repository, a last generic tier accepts a near-square +`icon`/`logo`/`appicon` image (`svg`/`png`/`webp`) at the root, `assets/`, or +`.github/`; wide wordmark logos and banners are rejected by an aspect-ratio +gate. Detection never runs for existing repositories, workspaces, or repos +that already have an icon, and it never replaces a manual choice. **Clear Icon** removes a detected icon and suppresses re-detection; only removing and re-adding the repository triggers a fresh scan. Detected icons keep their original colors (they are never tinted, unlike user-picked SVGs/symbols). diff --git a/supacode/Clients/Repositories/RepositoryIconComposerRenderer.swift b/supacode/Clients/Repositories/RepositoryIconComposerRenderer.swift new file mode 100644 index 00000000..f57107ae --- /dev/null +++ b/supacode/Clients/Repositories/RepositoryIconComposerRenderer.swift @@ -0,0 +1,40 @@ +import AppKit +import Foundation +import QuickLookThumbnailing + +/// Flattens an Icon Composer `.icon` bundle into a plain PNG via the +/// system QuickLook thumbnail pipeline. The bundle format is layered +/// (background fill, glass layers, per-appearance specializations), so +/// compositing ourselves would misrepresent the icon — QuickLook's own +/// renderer is the source of truth. Only a real `.thumbnail` +/// representation is accepted: on machines without the Icon Composer +/// QuickLook support the request fails and detection just falls +/// through to the asset-catalog probe. +nonisolated enum RepositoryIconComposerRenderer { + static func render(_ iconBundleURL: URL) async -> URL? { + let request = QLThumbnailGenerator.Request( + fileAt: iconBundleURL, + size: CGSize(width: 512, height: 512), + scale: 2, + representationTypes: .thumbnail + ) + guard + let representation = try? await QLThumbnailGenerator.shared + .generateBestRepresentation(for: request) + else { + return nil + } + let bitmap = NSBitmapImageRep(cgImage: representation.cgImage) + guard let data = bitmap.representation(using: .png, properties: [:]), !data.isEmpty else { + return nil + } + let destination = FileManager.default.temporaryDirectory + .appending(path: "prowl-icon-composer-\(UUID().uuidString).png", directoryHint: .notDirectory) + do { + try data.write(to: destination, options: [.atomic]) + } catch { + return nil + } + return destination + } +} diff --git a/supacode/Clients/Repositories/RepositoryIconDetector.swift b/supacode/Clients/Repositories/RepositoryIconDetector.swift index 7bd48bb6..915833b5 100644 --- a/supacode/Clients/Repositories/RepositoryIconDetector.swift +++ b/supacode/Clients/Repositories/RepositoryIconDetector.swift @@ -2,14 +2,19 @@ import AppKit import Foundation import ImageIO -/// A validated icon file found inside a repository, plus the project -/// evidence that made it trustworthy. The detector never mutates the -/// repository or settings — committing a candidate is the reducer's job. +/// A validated icon file plus the project evidence that made it +/// trustworthy. `imageURL` normally points inside the repository; for +/// Icon Composer bundles it is a freshly rendered temp PNG. The +/// detector never mutates the repository or settings — committing a +/// candidate is the reducer's job. nonisolated struct RepositoryIconCandidate: Equatable, Sendable { enum Evidence: Equatable, Sendable { + case appleIconComposer case appleAssetCatalog case androidLauncher + case tauriBundle case webAsset + case genericAsset } let imageURL: URL @@ -18,16 +23,22 @@ nonisolated struct RepositoryIconCandidate: Equatable, Sendable { /// Evidence-based local probe for a repository's own product icon. /// -/// Kinds are tried in a fixed order — Flutter, React Native, Apple, -/// Android, Web — and each kind requires its own positive project -/// signal before probing, falling through when it yields no valid -/// candidate. Traversal is bounded (depth, entry budget, skip list, -/// no symlinks) so the scan stays cheap even in huge repositories, -/// and every candidate is validated (contained in the repo, regular -/// file, bounded size, decodable) before being returned. +/// Kinds are tried in a fixed order — Flutter, React Native, Tauri, +/// Apple, Android, Web — and each kind requires its own positive +/// project signal before probing, falling through when it yields no +/// valid candidate. A last generic tier accepts a near-square +/// `icon`/`logo` asset at a conventional location for any repository. +/// Traversal is bounded (depth, entry budget, skip list, no symlinks) +/// so the scan stays cheap even in huge repositories, and every +/// candidate is validated (contained in the repo, regular file, +/// bounded size, decodable) before being returned. nonisolated enum RepositoryIconDetector { - static func detect(at rootURL: URL, fileManager: FileManager = .default) -> RepositoryIconCandidate? { + static func detect( + at rootURL: URL, + fileManager: FileManager = .default, + renderIconComposer: (URL) async -> URL? = RepositoryIconComposerRenderer.render + ) async -> RepositoryIconCandidate? { let scanner = Scanner(rootURL: rootURL, fileManager: fileManager) let names = scanner.entryNames(of: rootURL) guard !names.isEmpty else { return nil } @@ -44,12 +55,22 @@ nonisolated enum RepositoryIconDetector { { return candidate } + if names.contains("src-tauri"), + let imageURL = probeTauriBundle(scanner: scanner) + { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .tauriBundle) + } if names.contains(where: { $0.hasSuffix(".xcodeproj") || $0.hasSuffix(".xcworkspace") - }) || names.contains("package.swift") || names.contains("project.swift"), - let imageURL = probeAppleCatalog(under: scanner.rootURL, scanner: scanner) - { - return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleAssetCatalog) + }) || names.contains("package.swift") || names.contains("project.swift") { + if let imageURL = await probeIconComposer( + under: scanner.rootURL, scanner: scanner, render: renderIconComposer + ) { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleIconComposer) + } + if let imageURL = probeAppleCatalog(under: scanner.rootURL, scanner: scanner) { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleAssetCatalog) + } } if names.contains("settings.gradle") || names.contains("settings.gradle.kts") || names.contains("build.gradle") || names.contains("build.gradle.kts") @@ -63,6 +84,9 @@ nonisolated enum RepositoryIconDetector { { return RepositoryIconCandidate(imageURL: imageURL, evidence: .webAsset) } + if let imageURL = probeGenericAsset(scanner: scanner) { + return RepositoryIconCandidate(imageURL: imageURL, evidence: .genericAsset) + } return nil } @@ -82,8 +106,59 @@ nonisolated enum RepositoryIconDetector { return nil } + // MARK: - Tauri + + /// Reads `src-tauri/tauri.conf.json` (v1 and v2 shapes) and tries the + /// declared bundle icons, hoisting the conventional flat `icon.png`. + /// The config's presence is itself the project signal. + private static func probeTauriBundle(scanner: Scanner) -> URL? { + let tauriDirectory = scanner.rootURL.appending(path: "src-tauri", directoryHint: .isDirectory) + let configURL = tauriDirectory.appending(path: "tauri.conf.json", directoryHint: .notDirectory) + guard let data = scanner.boundedContents(of: configURL, limit: 512 * 1024), + let config = try? JSONDecoder().decode(TauriConfiguration.self, from: data) + else { + return nil + } + let declared = config.bundle?.icon ?? config.tauri?.bundle?.icon ?? [] + let safe = declared.filter { !$0.contains(":") } + let preferred = safe.filter { $0.lowercased().hasSuffix("icon.png") } + let rest = safe.filter { !$0.lowercased().hasSuffix("icon.png") } + for reference in preferred + rest { + let candidate = tauriDirectory.appending(path: reference, directoryHint: .notDirectory) + if scanner.validatedImage(at: candidate) != nil { + return candidate + } + } + return nil + } + // MARK: - Apple + /// Finds Icon Composer `.icon` bundles (nearest to the root first) + /// and asks QuickLook to flatten the first structurally valid one. + /// The bundle must sit inside the repository and carry a JSON + /// `icon.json`; the render itself happens through the injected + /// closure so tests can avoid the system thumbnail pipeline. + private static func probeIconComposer( + under directory: URL, + scanner: Scanner, + render: (URL) async -> URL? + ) async -> URL? { + let bundles = scanner.findDirectories(withExtension: "icon", under: directory, maxDepth: 6) + for bundle in bundles { + let manifestURL = bundle.appending(path: "icon.json", directoryHint: .notDirectory) + guard let data = scanner.boundedContents(of: manifestURL, limit: 1024 * 1024), + (try? JSONSerialization.jsonObject(with: data)) is [String: Any] + else { + continue + } + if let rendered = await render(bundle) { + return rendered + } + } + return nil + } + /// Finds `AppIcon.appiconset` catalogs under `directory` (nearest to /// the root first, then lexicographic) and returns the largest valid /// raster referenced by the first catalog that yields one. @@ -179,6 +254,14 @@ nonisolated enum RepositoryIconDetector { let publicDirectory = root.appending(path: "public", directoryHint: .isDirectory) let searchDirectories = [root, publicDirectory] + // An explicit `"icon"` declaration in package.json (VS Code + // extensions and friends) outranks every convention-based source. + if let declared = packageJSONIconPath(scanner: scanner) { + let references = resolveWebReferences(declared, baseDirectory: root, scanner: scanner) + for imageURL in references where scanner.validatedImage(at: imageURL) != nil { + return imageURL + } + } for directory in searchDirectories { for name in ["manifest.webmanifest", "site.webmanifest", "manifest.json"] { let manifestURL = directory.appending(path: name, directoryHint: .notDirectory) @@ -210,6 +293,50 @@ nonisolated enum RepositoryIconDetector { return nil } + private static func packageJSONIconPath(scanner: Scanner) -> String? { + struct Manifest: Decodable { + let icon: String? + } + let url = scanner.rootURL.appending(path: "package.json", directoryHint: .notDirectory) + guard let data = scanner.boundedContents(of: url, limit: 512 * 1024), + let manifest = try? JSONDecoder().decode(Manifest.self, from: data), + let icon = manifest.icon, !icon.isEmpty + else { + return nil + } + return icon + } + + // MARK: - Generic fallback + + private static let genericAssetDirectories = ["", "assets", ".github"] + private static let genericAssetStems = ["appicon", "app-icon", "icon", "logo"] + private static let genericAssetExtensions = ["svg", "png", "webp"] + + /// Last tier for repositories no kind-specific probe covered: a + /// conventionally named icon/logo at a conventional location. The + /// near-square gate is what keeps confidence high — wide wordmark + /// logos and social banners are rejected outright. + private static func probeGenericAsset(scanner: Scanner) -> URL? { + for directoryName in genericAssetDirectories { + let directory = + directoryName.isEmpty + ? scanner.rootURL + : scanner.rootURL.appending(path: directoryName, directoryHint: .isDirectory) + for stem in genericAssetStems { + for fileExtension in genericAssetExtensions { + let candidate = directory.appending( + path: "\(stem).\(fileExtension)", directoryHint: .notDirectory + ) + if scanner.validatedNearSquareImage(at: candidate) != nil { + return candidate + } + } + } + } + return nil + } + private static func largestValidManifestIcon( at manifestURL: URL, baseDirectory: URL, @@ -331,3 +458,17 @@ nonisolated private struct WebManifest: Decodable { let icons: [Icon]? } + +/// `tauri.conf.json` shape — v2 keeps `bundle` at the top level, v1 +/// nests it under `tauri`; decoded fields only. +nonisolated private struct TauriConfiguration: Decodable { + struct Bundle: Decodable { + let icon: [String]? + } + struct Nested: Decodable { + let bundle: Bundle? + } + + let bundle: Bundle? + let tauri: Nested? +} diff --git a/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift b/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift index c6d18283..85a79662 100644 --- a/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift +++ b/supacode/Clients/Repositories/RepositoryIconDetectorClient.swift @@ -11,7 +11,7 @@ nonisolated enum RepositoryIconDetectorClientKey: DependencyKey { static var liveValue: RepositoryIconDetectorClient { RepositoryIconDetectorClient( detect: { rootURL in - RepositoryIconDetector.detect(at: rootURL) + await RepositoryIconDetector.detect(at: rootURL) } ) } diff --git a/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift b/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift index 96309ddf..34cfad4d 100644 --- a/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift +++ b/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift @@ -16,8 +16,11 @@ extension RepositoryIconDetector { "pods", "target", "vendor", ] private static let allowedImageExtensions: Set = [ - "png", "jpg", "jpeg", "webp", "ico", "svg", + "png", "jpg", "jpeg", "webp", "ico", "icns", "svg", ] + /// Widest width:height ratio the near-square gate accepts; wide + /// wordmark logos and social banners fail this on purpose. + private static let maxNearSquareAspectRatio = 1.5 private static let maxImageBytes = 5 * 1024 * 1024 private static let maxRasterPixelDimension = 4096 private static let minRasterPixelDimension = 16 @@ -62,6 +65,24 @@ extension RepositoryIconDetector { /// lexicographically within one depth. Respects the shared entry /// budget and cooperative cancellation. func findDirectories(named lowercasedName: String, under directory: URL, maxDepth: Int) -> [URL] { + findDirectories(under: directory, maxDepth: maxDepth) { + $0.lastPathComponent.lowercased() == lowercasedName + } + } + + /// Same search keyed on a directory extension (e.g. Icon Composer + /// `.icon` bundles, which carry arbitrary base names). + func findDirectories(withExtension lowercasedExtension: String, under directory: URL, maxDepth: Int) -> [URL] { + findDirectories(under: directory, maxDepth: maxDepth) { + $0.pathExtension.lowercased() == lowercasedExtension + } + } + + private func findDirectories( + under directory: URL, + maxDepth: Int, + matches: (URL) -> Bool + ) -> [URL] { var found: [URL] = [] var frontier = [directory] var depth = 0 @@ -69,7 +90,7 @@ extension RepositoryIconDetector { var next: [URL] = [] for parent in frontier { for child in childDirectories(of: parent) { - if child.lastPathComponent.lowercased() == lowercasedName { + if matches(child) { found.append(child) } else { next.append(child) @@ -99,6 +120,21 @@ extension RepositoryIconDetector { /// after resolving symlinks, a regular file of bounded size, an /// allowed format, and actually decodable at a sane pixel size. func validatedImage(at url: URL) -> URL? { + validatedImageSize(at: url) == nil ? nil : url + } + + /// `validatedImage` plus a near-square gate: wide wordmark logos + /// and banner images make terrible sidebar icons, so the generic + /// fallback tier refuses them. + func validatedNearSquareImage(at url: URL) -> URL? { + guard let size = validatedImageSize(at: url), size.width > 0, size.height > 0 else { + return nil + } + let ratio = max(size.width, size.height) / min(size.width, size.height) + return ratio <= Scanner.maxNearSquareAspectRatio ? url : nil + } + + private func validatedImageSize(at url: URL) -> CGSize? { let fileExtension = url.pathExtension.lowercased() guard Scanner.allowedImageExtensions.contains(fileExtension) else { return nil } let resolved = url.resolvingSymlinksInPath() @@ -115,9 +151,9 @@ extension RepositoryIconDetector { return nil } if fileExtension == "svg" { - return isDecodableSVG(at: resolved) ? url : nil + return decodableSVGSize(at: resolved) } - return isDecodableRaster(at: resolved) ? url : nil + return decodableRasterSize(at: resolved) } // MARK: - Private @@ -146,32 +182,37 @@ extension RepositoryIconDetector { /// Cheap structural sniff plus a real decode. `NSImage` is the same /// renderer the app uses later, so a pass here guarantees the icon /// won't turn into the missing-file placeholder. - private func isDecodableSVG(at url: URL) -> Bool { + private func decodableSVGSize(at url: URL) -> CGSize? { guard let prefix = boundedContents(of: url, limit: Scanner.maxImageBytes), let head = String(data: prefix.prefix(4096), encoding: .utf8), head.localizedCaseInsensitiveContains(" 0, image.size.height > 0 + else { + return nil } - guard let image = NSImage(contentsOf: url) else { return false } - return image.size.width > 0 && image.size.height > 0 + return image.size } /// Metadata-only probe via ImageIO — no bitmap is decompressed, so /// a decompression bomb can't hurt us; the pixel cap keeps later /// rendering bounded too. - private func isDecodableRaster(at url: URL) -> Bool { + private func decodableRasterSize(at url: URL) -> CGSize? { let options = [kCGImageSourceShouldCache: false] as CFDictionary guard let source = CGImageSourceCreateWithURL(url as CFURL, options), CGImageSourceGetCount(source) > 0, let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, options) as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? Int, - let height = properties[kCGImagePropertyPixelHeight] as? Int + let height = properties[kCGImagePropertyPixelHeight] as? Int, + (Scanner.minRasterPixelDimension...Scanner.maxRasterPixelDimension).contains(width), + (Scanner.minRasterPixelDimension...Scanner.maxRasterPixelDimension).contains(height) else { - return false + return nil } - return (Scanner.minRasterPixelDimension...Scanner.maxRasterPixelDimension).contains(width) - && (Scanner.minRasterPixelDimension...Scanner.maxRasterPixelDimension).contains(height) + return CGSize(width: width, height: height) } } } diff --git a/supacode/Domain/WorktreeProjectKind.swift b/supacode/Domain/WorktreeProjectKind.swift index 3eaccb9c..242c709b 100644 --- a/supacode/Domain/WorktreeProjectKind.swift +++ b/supacode/Domain/WorktreeProjectKind.swift @@ -5,6 +5,7 @@ import Foundation enum WorktreeProjectKind: CaseIterable { case flutter case reactNative + case unity case apple case android case dotnet @@ -41,6 +42,13 @@ enum WorktreeProjectKind: CaseIterable { { return .reactNative } + // Before the generic language markers: a Unity project generates + // root-level `.sln`/`.csproj` files that would otherwise win as + // .NET, and SDK-style repos (a Unity project one folder down next + // to tooling manifests) would fall to whatever the tooling uses. + if isUnityProject(entries: entries, names: names, in: directory, fileManager: fileManager) { + return .unity + } if hasFile(withExtension: "xcodeproj") || hasFile(withExtension: "xcworkspace") || names.contains("package.swift") || names.contains("project.swift") { @@ -107,6 +115,37 @@ enum WorktreeProjectKind: CaseIterable { return contents.contains(/^\s*flutter\s*:/.anchorsMatchLineEndings()) } + /// A Unity project is proven by `ProjectSettings/ProjectVersion.txt` + /// — either at the root, or one level down, which covers SDK-style + /// repos that keep the Unity test project next to tooling manifests + /// (a `Gemfile` or `package.json` at the root must not outrank an + /// actual Unity project). The nested pass is bounded and skips + /// hidden entries. + nonisolated private static func isUnityProject( + entries: [String], + names: Set, + in directory: URL, + fileManager: FileManager + ) -> Bool { + func hasProjectVersion(in projectDirectory: URL) -> Bool { + let versionFile = + projectDirectory + .appending(path: "ProjectSettings", directoryHint: .isDirectory) + .appending(path: "ProjectVersion.txt", directoryHint: .notDirectory) + return fileManager.fileExists(atPath: versionFile.path(percentEncoded: false)) + } + if names.contains("projectsettings"), hasProjectVersion(in: directory) { + return true + } + for entry in entries.sorted().prefix(50) where !entry.hasPrefix(".") { + let child = directory.appending(path: entry, directoryHint: .isDirectory) + if hasProjectVersion(in: child) { + return true + } + } + return false + } + /// React Native needs `react-native` as a declared dependency; the /// `ios`/`android` shell folders alone are checked by the caller. /// Internal because `RepositoryIconDetector` reuses the same signal. @@ -137,6 +176,7 @@ enum WorktreeProjectKind: CaseIterable { switch self { case .flutter: [.androidStudio, .intellij, .intellijEAP] + Self.vsCodeFamily case .reactNative: Self.vsCodeFamily + [.webstorm, .androidStudio] + case .unity: [.rider] + Self.vsCodeFamily case .apple: [.xcode] case .android: [.androidStudio, .intellij, .intellijEAP] case .dotnet: [.rider] diff --git a/supacodeTests/RepositoryIconDetectorTests.swift b/supacodeTests/RepositoryIconDetectorTests.swift index 61360553..683d340b 100644 --- a/supacodeTests/RepositoryIconDetectorTests.swift +++ b/supacodeTests/RepositoryIconDetectorTests.swift @@ -1,4 +1,5 @@ import AppKit +import Dependencies import Foundation import Testing @@ -62,8 +63,8 @@ struct RepositoryIconDetectorTests { // MARK: - Apple - @Test func appleCatalogPicksLargestReferencedRaster() throws { - try withTemporaryProjectDirectory( + @Test func appleCatalogPicksLargestReferencedRaster() async throws { + try await withTemporaryProjectDirectory( entries: ["App.xcodeproj/"], contents: [ "App/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ @@ -74,14 +75,14 @@ struct RepositoryIconDetectorTests { "App/Assets.xcassets/AppIcon.appiconset/large.png": pngData(width: 1024, height: 1024), ] ) { root in - let candidate = RepositoryIconDetector.detect(at: root) + let candidate = await RepositoryIconDetector.detect(at: root) #expect(candidate?.evidence == .appleAssetCatalog) #expect(candidate?.imageURL.lastPathComponent == "large.png") } } - @Test func appleCatalogFallsBackWhenLargestEntryFileIsMissing() throws { - try withTemporaryProjectDirectory( + @Test func appleCatalogFallsBackWhenLargestEntryFileIsMissing() async throws { + try await withTemporaryProjectDirectory( entries: ["Package.swift"], contents: [ "Sources/App/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ @@ -91,24 +92,24 @@ struct RepositoryIconDetectorTests { "Sources/App/Assets.xcassets/AppIcon.appiconset/present.png": pngData(width: 128, height: 128), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "present.png") + #expect(await RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "present.png") } } - @Test func appleCatalogWithMalformedManifestYieldsNothing() throws { - try withTemporaryProjectDirectory( + @Test func appleCatalogWithMalformedManifestYieldsNothing() async throws { + try await withTemporaryProjectDirectory( entries: ["App.xcodeproj/"], contents: [ "Assets.xcassets/AppIcon.appiconset/Contents.json": Data("not json".utf8), "Assets.xcassets/AppIcon.appiconset/icon.png": pngData(width: 64, height: 64), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } - @Test func appleCatalogInsideDependencyDirectoryIsIgnored() throws { - try withTemporaryProjectDirectory( + @Test func appleCatalogInsideDependencyDirectoryIsIgnored() async throws { + try await withTemporaryProjectDirectory( entries: ["App.xcodeproj/"], contents: [ "node_modules/pkg/Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ @@ -117,12 +118,12 @@ struct RepositoryIconDetectorTests { "node_modules/pkg/Assets.xcassets/AppIcon.appiconset/icon.png": pngData(width: 64, height: 64), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } - @Test func nearerAppleCatalogWinsOverDeeperOne() throws { - try withTemporaryProjectDirectory( + @Test func nearerAppleCatalogWinsOverDeeperOne() async throws { + try await withTemporaryProjectDirectory( entries: ["App.xcworkspace/"], contents: [ "Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ @@ -135,41 +136,41 @@ struct RepositoryIconDetectorTests { "Modules/Deep/Assets.xcassets/AppIcon.appiconset/deep.png": pngData(width: 1024, height: 1024), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "near.png") + #expect(await RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "near.png") } } // MARK: - Android - @Test func androidLauncherPrefersHighestDensity() throws { - try withTemporaryProjectDirectory( + @Test func androidLauncherPrefersHighestDensity() async throws { + try await withTemporaryProjectDirectory( entries: ["gradlew", "settings.gradle"], contents: [ "app/src/main/res/mipmap-mdpi/ic_launcher.png": pngData(width: 48, height: 48), "app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": pngData(width: 192, height: 192), ] ) { root in - let candidate = RepositoryIconDetector.detect(at: root) + let candidate = await RepositoryIconDetector.detect(at: root) #expect(candidate?.evidence == .androidLauncher) #expect(candidate?.imageURL.path(percentEncoded: false).contains("mipmap-xxxhdpi") == true) } } - @Test func androidAdaptiveOnlyProjectYieldsNothing() throws { - try withTemporaryProjectDirectory( + @Test func androidAdaptiveOnlyProjectYieldsNothing() async throws { + try await withTemporaryProjectDirectory( entries: ["gradlew"], contents: [ "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml": Data("".utf8) ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } // MARK: - Flutter / React Native - @Test func flutterPrefersIOSRunnerCatalogOverAndroidLauncher() throws { - try withTemporaryProjectDirectory( + @Test func flutterPrefersIOSRunnerCatalogOverAndroidLauncher() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "pubspec.yaml": flutterPubspec, @@ -181,38 +182,38 @@ struct RepositoryIconDetectorTests { "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": pngData(width: 192, height: 192), ] ) { root in - let candidate = RepositoryIconDetector.detect(at: root) + let candidate = await RepositoryIconDetector.detect(at: root) #expect(candidate?.evidence == .appleAssetCatalog) #expect(candidate?.imageURL.path(percentEncoded: false).contains("ios/Runner") == true) } } - @Test func flutterFallsBackToAndroidLauncher() throws { - try withTemporaryProjectDirectory( + @Test func flutterFallsBackToAndroidLauncher() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "pubspec.yaml": flutterPubspec, "android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": pngData(width: 96, height: 96), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root)?.evidence == .androidLauncher) + #expect(await RepositoryIconDetector.detect(at: root)?.evidence == .androidLauncher) } } - @Test func pureDartPackageWithoutFlutterYieldsNothing() throws { - try withTemporaryProjectDirectory( + @Test func pureDartPackageWithoutFlutterYieldsNothing() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "pubspec.yaml": Data("name: pure_dart\nenvironment:\n sdk: ^3.0.0\n".utf8), "android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": pngData(width: 96, height: 96), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } - @Test func reactNativeUsesIOSCatalogThenAndroid() throws { - try withTemporaryProjectDirectory( + @Test func reactNativeUsesIOSCatalogThenAndroid() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": reactNativePackageJSON, @@ -222,15 +223,15 @@ struct RepositoryIconDetectorTests { "ios/Demo/Images.xcassets/AppIcon.appiconset/AppIcon.png": pngData(width: 1024, height: 1024), ] ) { root in - let candidate = RepositoryIconDetector.detect(at: root) + let candidate = await RepositoryIconDetector.detect(at: root) #expect(candidate?.evidence == .appleAssetCatalog) } } // MARK: - Web - @Test func webManifestIconWinsOverFavicon() throws { - try withTemporaryProjectDirectory( + @Test func webManifestIconWinsOverFavicon() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8), @@ -243,14 +244,14 @@ struct RepositoryIconDetectorTests { "public/favicon.png": pngData(width: 32, height: 32), ] ) { root in - let candidate = RepositoryIconDetector.detect(at: root) + let candidate = await RepositoryIconDetector.detect(at: root) #expect(candidate?.evidence == .webAsset) #expect(candidate?.imageURL.lastPathComponent == "icon-512.png") } } - @Test func htmlRelIconIsResolvedAgainstRoot() throws { - try withTemporaryProjectDirectory( + @Test func htmlRelIconIsResolvedAgainstRoot() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8), @@ -260,49 +261,51 @@ struct RepositoryIconDetectorTests { "assets/fav.png": pngData(width: 64, height: 64), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "fav.png") + #expect(await RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "fav.png") } } - @Test func staticFolderWithIndexHTMLAndFaviconQualifies() throws { - try withTemporaryProjectDirectory( + @Test func staticFolderWithIndexHTMLAndFaviconQualifies() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "index.html": Data("".utf8), "favicon.svg": svgData, ] ) { root in - let candidate = RepositoryIconDetector.detect(at: root) + let candidate = await RepositoryIconDetector.detect(at: root) #expect(candidate?.evidence == .webAsset) #expect(candidate?.imageURL.lastPathComponent == "favicon.svg") } } - @Test func rootLogoIsLastResortForWebProjects() throws { - try withTemporaryProjectDirectory( + @Test func rootLogoIsLastResortForWebProjects() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8), "logo.svg": svgData, ] ) { root in - #expect(RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "logo.svg") + #expect(await RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "logo.svg") } } - @Test func nonWebProjectIgnoresRootLogo() throws { - try withTemporaryProjectDirectory( + @Test func nonWebProjectRootLogoFallsToGenericTier() async throws { + // Originally rejected outright; the generic fallback tier now + // accepts a near-square root logo for any project kind. + try await withTemporaryProjectDirectory( entries: ["go.mod"], contents: [ "logo.svg": svgData ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root)?.evidence == .genericAsset) } } - @Test func remoteAndDataIconReferencesAreRejected() throws { - try withTemporaryProjectDirectory( + @Test func remoteAndDataIconReferencesAreRejected() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8), @@ -311,43 +314,43 @@ struct RepositoryIconDetectorTests { ), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } // MARK: - Validation - @Test func undecodableImageIsRejected() throws { - try withTemporaryProjectDirectory( + @Test func undecodableImageIsRejected() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8), "logo.png": Data("this is not a png".utf8), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } - @Test func oversizedRasterIsRejected() throws { - try withTemporaryProjectDirectory( + @Test func oversizedRasterIsRejected() async throws { + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8), "logo.png": pngData(width: 5000, height: 16), ] ) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } - @Test func symlinkEscapingTheRepositoryIsRejected() throws { + @Test func symlinkEscapingTheRepositoryIsRejected() async throws { let fileManager = FileManager.default let outside = fileManager.temporaryDirectory .appending(path: "outside-\(UUID().uuidString).png") try pngData(width: 64, height: 64).write(to: outside) defer { try? fileManager.removeItem(at: outside) } - try withTemporaryProjectDirectory( + try await withTemporaryProjectDirectory( entries: [], contents: [ "package.json": Data(#"{"name":"site"}"#.utf8) @@ -357,13 +360,182 @@ struct RepositoryIconDetectorTests { at: root.appending(path: "logo.png"), withDestinationURL: outside ) - #expect(RepositoryIconDetector.detect(at: root) == nil) + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } - @Test func emptyRepositoryYieldsNothing() throws { - try withTemporaryProjectDirectory(entries: ["README.md"]) { root in - #expect(RepositoryIconDetector.detect(at: root) == nil) + @Test func emptyRepositoryYieldsNothing() async throws { + try await withTemporaryProjectDirectory(entries: ["README.md"]) { root in + #expect(await RepositoryIconDetector.detect(at: root) == nil) + } + } + + // MARK: - Icon Composer + + @Test func iconComposerBundleWinsOverAssetCatalog() async throws { + let rendered = FileManager.default.temporaryDirectory + .appending(path: "rendered-\(UUID().uuidString).png") + try pngData(width: 512, height: 512).write(to: rendered) + defer { try? FileManager.default.removeItem(at: rendered) } + try await withTemporaryProjectDirectory( + entries: ["App.xcodeproj/"], + contents: [ + "assets/AppIcon.icon/icon.json": Data(#"{"fill":{"solid":"srgb:1,1,1,1"},"groups":[]}"#.utf8), + "assets/AppIcon.icon/Assets/layer.svg": svgData, + "Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "legacy.png", size: "512x512", scale: "1x") + ]), + "Assets.xcassets/AppIcon.appiconset/legacy.png": pngData(width: 512, height: 512), + ] + ) { root in + let requested = LockIsolated<[URL]>([]) + let candidate = await RepositoryIconDetector.detect(at: root) { bundle in + requested.withValue { $0.append(bundle) } + return rendered + } + #expect(candidate?.evidence == .appleIconComposer) + #expect(candidate?.imageURL == rendered) + #expect(requested.value.first?.lastPathComponent == "AppIcon.icon") + } + } + + @Test func unrenderableIconComposerBundleFallsBackToAssetCatalog() async throws { + try await withTemporaryProjectDirectory( + entries: ["App.xcodeproj/"], + contents: [ + "AppIcon.icon/icon.json": Data(#"{"groups":[]}"#.utf8), + "Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "legacy.png", size: "512x512", scale: "1x") + ]), + "Assets.xcassets/AppIcon.appiconset/legacy.png": pngData(width: 512, height: 512), + ] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) { _ in nil } + #expect(candidate?.evidence == .appleAssetCatalog) + } + } + + @Test func iconDirectoryWithoutManifestIsNotRendered() async throws { + try await withTemporaryProjectDirectory( + entries: ["Package.swift", "Some.icon/"] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) { _ in + Issue.record("Renderer must not run without a valid icon.json") + return nil + } + #expect(candidate == nil) + } + } + + // MARK: - Tauri + + @Test func tauriBundleIconIsUsed() async throws { + try await withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"app"}"#.utf8), + "src-tauri/tauri.conf.json": Data( + #"{"bundle":{"icon":["icons/32x32.png","icons/icon.icns","icons/icon.png"]}}"#.utf8 + ), + "src-tauri/icons/32x32.png": pngData(width: 32, height: 32), + "src-tauri/icons/icon.png": pngData(width: 1024, height: 1024), + ] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .tauriBundle) + #expect(candidate?.imageURL.lastPathComponent == "icon.png") + } + } + + @Test func tauriV1ConfigShapeIsSupported() async throws { + try await withTemporaryProjectDirectory( + entries: [], + contents: [ + "src-tauri/tauri.conf.json": Data( + #"{"tauri":{"bundle":{"icon":["icons/icon.png"]}}}"#.utf8 + ), + "src-tauri/icons/icon.png": pngData(width: 512, height: 512), + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root)?.evidence == .tauriBundle) + } + } + + // MARK: - package.json icon field + + @Test func packageJSONIconFieldOutranksFavicon() async throws { + try await withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"ext","icon":"images/ext-icon.png"}"#.utf8), + "images/ext-icon.png": pngData(width: 128, height: 128), + "public/favicon.png": pngData(width: 32, height: 32), + ] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .webAsset) + #expect(candidate?.imageURL.lastPathComponent == "ext-icon.png") + } + } + + // MARK: - Generic fallback + + @Test func genericTierAcceptsNearSquareRootIconForAnyKind() async throws { + try await withTemporaryProjectDirectory( + entries: ["go.mod"], + contents: [ + "icon.png": pngData(width: 256, height: 256) + ] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .genericAsset) + #expect(candidate?.imageURL.lastPathComponent == "icon.png") + } + } + + @Test func genericTierFindsAssetsAndGithubLocations() async throws { + try await withTemporaryProjectDirectory( + entries: ["Cargo.toml"], + contents: [ + ".github/logo.png": pngData(width: 300, height: 260) + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root)?.evidence == .genericAsset) + } + } + + @Test func genericTierRejectsWideWordmarkLogo() async throws { + try await withTemporaryProjectDirectory( + entries: ["go.mod"], + contents: [ + "assets/logo.png": pngData(width: 1200, height: 300) + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root) == nil) + } + } + + @Test func kindProbeStillWinsOverGenericTier() async throws { + try await withTemporaryProjectDirectory( + entries: ["gradlew"], + contents: [ + "app/src/main/res/mipmap-xhdpi/ic_launcher.png": pngData(width: 96, height: 96), + "icon.png": pngData(width: 256, height: 256), + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root)?.evidence == .androidLauncher) + } + } + + @Test func genericTierIgnoresUnrelatedFilenames() async throws { + try await withTemporaryProjectDirectory( + entries: ["go.mod"], + contents: [ + "banner.png": pngData(width: 256, height: 256), + "assets/screenshot.png": pngData(width: 256, height: 256), + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root) == nil) } } } diff --git a/supacodeTests/TemporaryProjectDirectory.swift b/supacodeTests/TemporaryProjectDirectory.swift index 72434c5f..d538a3b1 100644 --- a/supacodeTests/TemporaryProjectDirectory.swift +++ b/supacodeTests/TemporaryProjectDirectory.swift @@ -8,11 +8,29 @@ func withTemporaryProjectDirectory( contents: [String: Data] = [:], body: (URL) throws -> Void ) throws { + let directory = try makeTemporaryProjectDirectory(entries: entries, contents: contents) + defer { try? FileManager.default.removeItem(at: directory) } + try body(directory) +} + +func withTemporaryProjectDirectory( + entries: [String], + contents: [String: Data] = [:], + body: (URL) async throws -> Void +) async throws { + let directory = try makeTemporaryProjectDirectory(entries: entries, contents: contents) + defer { try? FileManager.default.removeItem(at: directory) } + try await body(directory) +} + +private func makeTemporaryProjectDirectory( + entries: [String], + contents: [String: Data] +) throws -> URL { let fileManager = FileManager.default let directory = fileManager.temporaryDirectory .appending(path: "project-fixture-\(UUID().uuidString)") try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directory) } for entry in entries { if entry.hasSuffix("/") { try fileManager.createDirectory( @@ -31,5 +49,5 @@ func withTemporaryProjectDirectory( ) try data.write(to: fileURL) } - try body(directory) + return directory } diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift index 21884577..a4c718fc 100644 --- a/supacodeTests/WorktreeProjectKindTests.swift +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -121,6 +121,45 @@ struct WorktreeProjectKindTests { } } + // MARK: - Unity + + @Test func detectsUnityFromRootProjectVersion() throws { + try withTemporaryProjectDirectory( + entries: ["Assets/", "Assembly-CSharp.csproj", "App.sln"], + contents: [ + "ProjectSettings/ProjectVersion.txt": Data("m_EditorVersion: 6000.4.5f1\n".utf8) + ] + ) { directory in + // Unity generates root .sln/.csproj files; the Unity claim must + // win over .NET. + #expect(WorktreeProjectKind.detect(at: directory) == .unity) + } + } + + @Test func detectsUnityProjectOneLevelDown() throws { + try withTemporaryProjectDirectory( + entries: ["Gemfile", "Rakefile", "Source/"], + contents: [ + "UniWebViewTest/ProjectSettings/ProjectVersion.txt": Data("m_EditorVersion: 6000.4.5f1\n".utf8) + ] + ) { directory in + // SDK-style repo: tooling manifests at the root, the Unity test + // project one folder down. Unity must win over Ruby. + #expect(WorktreeProjectKind.detect(at: directory) == .unity) + } + } + + @Test func projectSettingsFolderAloneIsNotUnity() throws { + try withTemporaryProjectDirectory(entries: ["ProjectSettings/", "go.mod"]) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .golang) + } + } + + @Test func unityPrefersRider() { + #expect(WorktreeProjectKind.unity.preferredActions.first == .rider) + #expect(WorktreeProjectKind.unity.preferredActions.contains(.vscode)) + } + @Test func hybridKindsPreferTheirDocumentedEditors() { #expect(WorktreeProjectKind.flutter.preferredActions.first == .androidStudio) #expect(WorktreeProjectKind.flutter.preferredActions.contains(.vscode)) -- 2.51.2 From 875a3a6a15e3631e28fd88eb58f54a5d3439813a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 25 Jul 2026 21:41:41 +0900 Subject: [PATCH 4/7] Harden icon detection against hostile manifests and fix review findings A crafted Contents.json or web manifest could trap the process: Int(Double.infinity) after unclamped size/scale multiplication, and checked Int overflow on declared manifest sizes. Both parse paths now clamp to finite, bounded dimensions. The React Native dependency check reads a bounded prefix instead of materializing an arbitrarily large package.json; SVG validation enforces the same pixel ceiling as rasters; Icon Composer renders are flagged as detector-owned temp files and deleted after import; suggestion buttons gain accessibility labels and hints with the best match called out. Claude-Session: https://claude.ai/code/session_01FzjRrTgza7QTwmaLY258rv --- .../Repositories/RepositoryIconDetector.swift | 33 +++++++++-- .../RepositoryIconDetectorScanner.swift | 15 +++-- supacode/Domain/WorktreeProjectKind.swift | 8 ++- .../RepositoriesFeature+IconDetection.swift | 11 ++-- .../RepositorySymbolSuggestionsSection.swift | 2 + ...epositoriesFeatureIconDetectionTests.swift | 39 +++++++++++++ .../RepositoryIconDetectorTests.swift | 55 +++++++++++++++++++ supacodeTests/WorktreeProjectKindTests.swift | 16 ++++++ 8 files changed, 165 insertions(+), 14 deletions(-) diff --git a/supacode/Clients/Repositories/RepositoryIconDetector.swift b/supacode/Clients/Repositories/RepositoryIconDetector.swift index 915833b5..f9c46552 100644 --- a/supacode/Clients/Repositories/RepositoryIconDetector.swift +++ b/supacode/Clients/Repositories/RepositoryIconDetector.swift @@ -19,6 +19,11 @@ nonisolated struct RepositoryIconCandidate: Equatable, Sendable { let imageURL: URL let evidence: Evidence + /// `true` when `imageURL` is a detector-produced temp artifact (an + /// Icon Composer render) that the consumer must delete once the + /// import — successful or not — is over. Repository-owned files are + /// never flagged and must never be deleted. + var ownsImageFile = false } /// Evidence-based local probe for a repository's own product icon. @@ -66,7 +71,9 @@ nonisolated enum RepositoryIconDetector { if let imageURL = await probeIconComposer( under: scanner.rootURL, scanner: scanner, render: renderIconComposer ) { - return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleIconComposer) + return RepositoryIconCandidate( + imageURL: imageURL, evidence: .appleIconComposer, ownsImageFile: true + ) } if let imageURL = probeAppleCatalog(under: scanner.rootURL, scanner: scanner) { return RepositoryIconCandidate(imageURL: imageURL, evidence: .appleAssetCatalog) @@ -198,15 +205,28 @@ nonisolated enum RepositoryIconDetector { } /// `size` is `"WxH"`, `scale` is `"Nx"`; a single-size catalog entry - /// may omit the scale. Unparseable entries rank last, not out. + /// may omit the scale. Unparseable entries rank last, not out. All + /// numbers are clamped before multiplying: a hostile manifest can + /// declare infinity/NaN/enormous values, and `Int(Double.infinity)` + /// would trap the whole process. private static func pixelArea(size: String?, scale: String?) -> Int { guard let size else { return 0 } - let dimensions = size.lowercased().split(separator: "x").compactMap { Double($0) } + let dimensions = size.lowercased().split(separator: "x") + .compactMap { clampedDimension($0, upperBound: 100_000) } guard dimensions.count == 2 else { return 0 } - let scaleFactor = scale.flatMap { Double($0.lowercased().replacing("x", with: "")) } ?? 1 + let scaleFactor = + scale + .map { $0.lowercased().replacing("x", with: "") } + .flatMap { clampedDimension($0, upperBound: 100) } ?? 1 return Int(dimensions[0] * scaleFactor * dimensions[1] * scaleFactor) } + /// Positive, finite, bounded — or nothing. + private static func clampedDimension(_ raw: some StringProtocol, upperBound: Double) -> Double? { + guard let value = Double(raw), value.isFinite, value > 0 else { return nil } + return min(value, upperBound) + } + // MARK: - Android private static let androidDensityDirectories = [ @@ -368,8 +388,11 @@ nonisolated enum RepositoryIconDetector { /// `sizes` is space-separated `"WxH"` tokens or `"any"` (scalable — /// ranked like a large raster so SVG icons win over small PNGs). + /// Dimensions outside a sane range are ignored: a hostile manifest + /// can declare `Int.max` and checked multiplication would trap. private static func declaredPixelArea(sizes: String?, source: String) -> Int { let scalableRank = 512 * 512 + let sane = 1...100_000 guard let sizes, !sizes.isEmpty else { return source.lowercased().hasSuffix(".svg") ? scalableRank : 0 } @@ -380,7 +403,7 @@ nonisolated enum RepositoryIconDetector { continue } let dimensions = token.split(separator: "x").compactMap { Int($0) } - if dimensions.count == 2 { + if dimensions.count == 2, dimensions.allSatisfy({ sane.contains($0) }) { best = max(best, dimensions[0] * dimensions[1]) } } diff --git a/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift b/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift index 34cfad4d..20912432 100644 --- a/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift +++ b/supacode/Clients/Repositories/RepositoryIconDetectorScanner.swift @@ -181,7 +181,10 @@ extension RepositoryIconDetector { /// Cheap structural sniff plus a real decode. `NSImage` is the same /// renderer the app uses later, so a pass here guarantees the icon - /// won't turn into the missing-file placeholder. + /// won't turn into the missing-file placeholder. The declared + /// canvas must be finite and within the same pixel ceiling as + /// rasters — vectors have no quality floor, so only the upper + /// bound applies. private func decodableSVGSize(at url: URL) -> CGSize? { guard let prefix = boundedContents(of: url, limit: Scanner.maxImageBytes), let head = String(data: prefix.prefix(4096), encoding: .utf8), @@ -189,12 +192,16 @@ extension RepositoryIconDetector { else { return nil } - guard let image = NSImage(contentsOf: url), - image.size.width > 0, image.size.height > 0 + guard let image = NSImage(contentsOf: url) else { return nil } + let size = image.size + let ceiling = CGFloat(Scanner.maxRasterPixelDimension) + guard size.width.isFinite, size.height.isFinite, + size.width > 0, size.height > 0, + size.width <= ceiling, size.height <= ceiling else { return nil } - return image.size + return size } /// Metadata-only probe via ImageIO — no bitmap is decompressed, so diff --git a/supacode/Domain/WorktreeProjectKind.swift b/supacode/Domain/WorktreeProjectKind.swift index 242c709b..43419672 100644 --- a/supacode/Domain/WorktreeProjectKind.swift +++ b/supacode/Domain/WorktreeProjectKind.swift @@ -149,13 +149,19 @@ enum WorktreeProjectKind: CaseIterable { /// React Native needs `react-native` as a declared dependency; the /// `ios`/`android` shell folders alone are checked by the caller. /// Internal because `RepositoryIconDetector` reuses the same signal. + /// The read is bounded up front — a pathological multi-hundred-MB + /// `package.json` must never be materialized on this path. nonisolated static func packageJSONDependsOnReactNative(in directory: URL) -> Bool { struct Manifest: Decodable { let dependencies: [String: String]? let devDependencies: [String: String]? } + let limit = 512 * 1024 let url = directory.appending(path: "package.json", directoryHint: .notDirectory) - guard let data = try? Data(contentsOf: url), data.count <= 512 * 1024, + guard let handle = try? FileHandle(forReadingFrom: url) else { return false } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: limit + 1), + data.count <= limit, let manifest = try? JSONDecoder().decode(Manifest.self, from: data) else { return false diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift index ca3dffb9..6e02a826 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+IconDetection.swift @@ -34,11 +34,14 @@ extension RepositoriesFeature { let assetStore = repositoryIconAssetStore effects.append( .run(priority: .utility) { send in - guard let candidate = await detector.detect(rootURL), !Task.isCancelled, - let filename = try? assetStore.importImage(candidate.imageURL, rootURL) - else { - return + guard let candidate = await detector.detect(rootURL), !Task.isCancelled else { return } + let filename = try? assetStore.importImage(candidate.imageURL, rootURL) + if candidate.ownsImageFile { + // Detector-produced temp artifact (Icon Composer render): + // delete it whether or not the import succeeded. + try? FileManager.default.removeItem(at: candidate.imageURL) } + guard let filename else { return } await send( .repositoryManagement(.repositoryIconDetected(repositoryID, filename: filename)) ) diff --git a/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift b/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift index 1c287e85..19fc0df2 100644 --- a/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift +++ b/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift @@ -80,6 +80,8 @@ struct RepositorySymbolSuggestionsSection: View { } .buttonStyle(.plain) .help(symbol) + .accessibilityLabel(symbol == suggestions.primary ? "\(symbol), best match" : symbol) + .accessibilityHint("Fills the symbol name field with this suggestion.") } Spacer(minLength: 0) } diff --git a/supacodeTests/RepositoriesFeatureIconDetectionTests.swift b/supacodeTests/RepositoriesFeatureIconDetectionTests.swift index 5dd9862f..36ca9910 100644 --- a/supacodeTests/RepositoriesFeatureIconDetectionTests.swift +++ b/supacodeTests/RepositoriesFeatureIconDetectionTests.swift @@ -410,6 +410,45 @@ struct RepositoriesFeatureIconDetectionTests { #expect(appearances()["/tmp/repo"]?.icon == .userImage(filename: "mine.png")) } + @Test func detectorOwnedTemporaryFileIsDeletedAfterImport() async throws { + let temp = FileManager.default.temporaryDirectory + .appending(path: "icon-composer-artifact-\(UUID().uuidString).png") + try Data([0xDE, 0xAD]).write(to: temp) + let newRepo = makeRepository(id: "/tmp/new", name: "new", kind: .plain) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { _ in + RepositoryIconCandidate( + imageURL: temp, + evidence: .appleIconComposer, + ownsImageFile: true + ) + } + $0.repositoryIconAssetStore = recordingAssetStore() + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [newRepo], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [newRepo.rootURL] + ) + ) + ) + await store.receive(\.repositoryManagement.repositoryIconDetected) + await store.finish() + + #expect(!FileManager.default.fileExists(atPath: temp.path(percentEncoded: false))) + #expect(appearances()["/tmp/new"]?.icon == .detectedImage(filename: "detected.png")) + } + // MARK: - Workspace exclusion @Test func workspaceContainersAreNeverScanned() async { diff --git a/supacodeTests/RepositoryIconDetectorTests.swift b/supacodeTests/RepositoryIconDetectorTests.swift index 683d340b..ad1168e4 100644 --- a/supacodeTests/RepositoryIconDetectorTests.swift +++ b/supacodeTests/RepositoryIconDetectorTests.swift @@ -395,6 +395,7 @@ struct RepositoryIconDetectorTests { } #expect(candidate?.evidence == .appleIconComposer) #expect(candidate?.imageURL == rendered) + #expect(candidate?.ownsImageFile == true) #expect(requested.value.first?.lastPathComponent == "AppIcon.icon") } } @@ -527,6 +528,60 @@ struct RepositoryIconDetectorTests { } } + // MARK: - Hostile manifests + + @Test func hostileAppIconManifestNumbersDoNotTrap() async throws { + // Infinity/NaN products must not reach `Int(_:)`, and the clamped + // hostile entry must not block a valid fallback candidate. + try await withTemporaryProjectDirectory( + entries: ["App.xcodeproj/"], + contents: [ + "Assets.xcassets/AppIcon.appiconset/Contents.json": appIconSetManifest([ + .init(filename: "evil.png", size: "1e308x1e308", scale: "1e308x"), + .init(filename: "weird.png", size: "nanxnan", scale: "nanx"), + .init(filename: "good.png", size: "128x128", scale: "1x"), + ]), + "Assets.xcassets/AppIcon.appiconset/evil.png": Data("not an image".utf8), + "Assets.xcassets/AppIcon.appiconset/good.png": pngData(width: 128, height: 128), + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root)?.imageURL.lastPathComponent == "good.png") + } + } + + @Test func hostileWebManifestSizesDoNotTrap() async throws { + // Int.max × 2 would overflow checked multiplication; out-of-range + // dimensions are ignored, the icon itself remains usable. + try await withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "manifest.json": Data( + #"{"icons":[{"src":"icon.png","sizes":"9223372036854775807x2 -5x-5"}]}"#.utf8 + ), + "icon.png": pngData(width: 256, height: 256), + ] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .webAsset) + #expect(candidate?.imageURL.lastPathComponent == "icon.png") + } + } + + @Test func oversizedSVGCanvasIsRejected() async throws { + let hugeSVG = + #""# + + #""# + try await withTemporaryProjectDirectory( + entries: ["package.json"], + contents: [ + "logo.svg": Data(hugeSVG.utf8) + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root) == nil) + } + } + @Test func genericTierIgnoresUnrelatedFilenames() async throws { try await withTemporaryProjectDirectory( entries: ["go.mod"], diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift index a4c718fc..67f95e4f 100644 --- a/supacodeTests/WorktreeProjectKindTests.swift +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -110,6 +110,22 @@ struct WorktreeProjectKindTests { } } + @Test func oversizedPackageJSONIsNotParsedForReactNative() throws { + // The dependency check must reject the file up front instead of + // materializing hundreds of megabytes on the classification path. + var oversized = #"{"dependencies":{"react-native":"0.80.0"},"padding":""# + oversized += String(repeating: "x", count: 600 * 1024) + oversized += #""}"# + try withTemporaryProjectDirectory( + entries: ["android/"], + contents: [ + "package.json": Data(oversized.utf8) + ] + ) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == .web) + } + } + @Test func plainPackageJSONWithNativeFoldersIsWeb() throws { try withTemporaryProjectDirectory( entries: ["ios/"], -- 2.51.2 From 5c33551d9d2ef50cedca81a9e0ff649498b90508 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 25 Jul 2026 21:56:58 +0900 Subject: [PATCH 5/7] Cover global opt-out cancellation of in-flight icon scans Reducer level: cancelPendingIconDetections tears down a parked scan without committing anything. App level: settingsChanged with detection disabled routes the cancellation into RepositoriesFeature. Claude-Session: https://claude.ai/code/session_01FzjRrTgza7QTwmaLY258rv --- .../AppFeatureSettingsChangedTests.swift | 13 ++++++++ ...epositoriesFeatureIconDetectionTests.swift | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 592c8bb0..2e950fcd 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -41,6 +41,19 @@ struct AppFeatureSettingsChangedTests { await store.finish() } + @Test(.dependencies) func disablingIconDetectionCancelsPendingScans() async { + var settings = GlobalSettings.default + settings.detectRepositoryIconsAutomatically = false + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.receive(\.repositories.repositoryManagement.cancelPendingIconDetections) + await store.finish() + } + @Test(.dependencies) func terminalFontSizeEventDoesNotFanOutGlobalSettingsEffects() async { let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) diff --git a/supacodeTests/RepositoriesFeatureIconDetectionTests.swift b/supacodeTests/RepositoriesFeatureIconDetectionTests.swift index 36ca9910..296ed857 100644 --- a/supacodeTests/RepositoriesFeatureIconDetectionTests.swift +++ b/supacodeTests/RepositoriesFeatureIconDetectionTests.swift @@ -410,6 +410,39 @@ struct RepositoriesFeatureIconDetectionTests { #expect(appearances()["/tmp/repo"]?.icon == .userImage(filename: "mine.png")) } + @Test func globalOptOutCancelsInFlightScans() async { + let newRepo = makeRepository(id: "/tmp/new", name: "new", kind: .plain) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryIconDetector.detect = { _ in + // Parks until cancelled, standing in for a long scan. + let (stream, _) = AsyncStream.makeStream() + for await _ in stream {} + return nil + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repositoryWebURL = { _ in nil } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [newRepo], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [newRepo.rootURL] + ) + ) + ) + await store.send(.repositoryManagement(.cancelPendingIconDetections)) + await store.finish() + + #expect(appearances()["/tmp/new"] == nil) + } + @Test func detectorOwnedTemporaryFileIsDeletedAfterImport() async throws { let temp = FileManager.default.temporaryDirectory .appending(path: "icon-composer-artifact-\(UUID().uuidString).png") -- 2.51.2 From 6ca4ef1aae8eac7ff8793bef0b8454d56d4777f3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 26 Jul 2026 14:24:31 +0900 Subject: [PATCH 6/7] Reject SVG icon candidates that rasterize fully transparent CoreSVG ignores embedded "# + return Data(svg.utf8) + } + private struct ManifestEntry { var filename: String var size: String @@ -568,6 +578,33 @@ struct RepositoryIconDetectorTests { } } + @Test func invisiblyRenderingSVGFaviconFallsBackToRasterFavicon() async throws { + try await withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "public/favicon.svg": styleColoredSVGData, + "public/favicon.png": pngData(width: 32, height: 32), + ] + ) { root in + let candidate = await RepositoryIconDetector.detect(at: root) + #expect(candidate?.evidence == .webAsset) + #expect(candidate?.imageURL.lastPathComponent == "favicon.png") + } + } + + @Test func invisiblyRenderingSVGAloneYieldsNothing() async throws { + try await withTemporaryProjectDirectory( + entries: [], + contents: [ + "package.json": Data(#"{"name":"site"}"#.utf8), + "public/favicon.svg": styleColoredSVGData, + ] + ) { root in + #expect(await RepositoryIconDetector.detect(at: root) == nil) + } + } + @Test func oversizedSVGCanvasIsRejected() async throws { let hugeSVG = #""# -- 2.51.2 From 47e247ba218cffa6aeff99300326e73b17b9e310 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 26 Jul 2026 16:17:04 +0900 Subject: [PATCH 7/7] Address Copilot review: close marker file handle via defer, clean up fixture on failure markerFileContents skipped the explicit close when read(upToCount:) failed, leaving the handle to deinit; a defer now covers every exit path. makeTemporaryProjectDirectory removes the half-built fixture directory before rethrowing so failed test setup doesn't strand orphans in the system temp directory. Claude-Session: https://claude.ai/code/session_01GXJQwWRppyA2kevaFd9FfH --- supacode/Domain/WorktreeProjectKind.swift | 9 ++---- supacodeTests/TemporaryProjectDirectory.swift | 31 +++++++++++-------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/supacode/Domain/WorktreeProjectKind.swift b/supacode/Domain/WorktreeProjectKind.swift index 43419672..9f139348 100644 --- a/supacode/Domain/WorktreeProjectKind.swift +++ b/supacode/Domain/WorktreeProjectKind.swift @@ -96,12 +96,9 @@ enum WorktreeProjectKind: CaseIterable { /// when a repo carries a pathological manifest. nonisolated private static func markerFileContents(named name: String, in directory: URL) -> String? { let url = directory.appending(path: name, directoryHint: .notDirectory) - guard let handle = try? FileHandle(forReadingFrom: url), - let data = try? handle.read(upToCount: 128 * 1024) - else { - return nil - } - try? handle.close() + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 128 * 1024) else { return nil } return String(data: data, encoding: .utf8) } diff --git a/supacodeTests/TemporaryProjectDirectory.swift b/supacodeTests/TemporaryProjectDirectory.swift index d538a3b1..399d0b47 100644 --- a/supacodeTests/TemporaryProjectDirectory.swift +++ b/supacodeTests/TemporaryProjectDirectory.swift @@ -31,23 +31,28 @@ private func makeTemporaryProjectDirectory( let directory = fileManager.temporaryDirectory .appending(path: "project-fixture-\(UUID().uuidString)") try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - for entry in entries { - if entry.hasSuffix("/") { + do { + for entry in entries { + if entry.hasSuffix("/") { + try fileManager.createDirectory( + at: directory.appending(path: String(entry.dropLast())), + withIntermediateDirectories: true + ) + } else { + try Data().write(to: directory.appending(path: entry)) + } + } + for (path, data) in contents { + let fileURL = directory.appending(path: path) try fileManager.createDirectory( - at: directory.appending(path: String(entry.dropLast())), + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - } else { - try Data().write(to: directory.appending(path: entry)) + try data.write(to: fileURL) } - } - for (path, data) in contents { - let fileURL = directory.appending(path: path) - try fileManager.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try data.write(to: fileURL) + } catch { + try? fileManager.removeItem(at: directory) + throw error } return directory }