diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d976c3fa5f..ee315c524c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,6 +22,7 @@ - [ ] My code builds and runs on my machine - [ ] My changes are all related to the related issue above - [ ] I documented my code +- [ ] New files follow the placement rules in [docs/ARCHITECTURE.md](https://github.com/CodeEditApp/CodeEdit/blob/main/docs/ARCHITECTURE.md) (features as packages, no cross-feature imports, purpose-first folders) ### Screenshots diff --git a/.github/scripts/audit_package_imports.py b/.github/scripts/audit_package_imports.py new file mode 100755 index 0000000000..2394c3f340 --- /dev/null +++ b/.github/scripts/audit_package_imports.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Audit the CodeEditModules package: every `import` must be declared in the +manifest, and the three rules from docs/ARCHITECTURE.md must hold. + +Why: for most targets this script is the ONLY defence against a leaky import. +Xcode workspace builds share one build directory, so an undeclared import of a +sibling target compiles fine. The usual backstop — "it breaks a standalone +`swift build`" — does not exist here: external CodeEditSymbols never declares +its .xcassets under `resources:`, so SwiftPM synthesises no `Bundle.module` +accessor and the dependency itself fails to compile. 7 of the 12 targets need it +transitively, so plain `swift build` is unavailable for them and nothing else +would catch the violation. Hence manifest honesty is a PR gate. + +Usage: python3 .github/scripts/audit_package_imports.py (from anywhere) +""" +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +PACKAGE = REPO / "CodeEditModules" +MANIFEST = PACKAGE / "Package.swift" + +# Apple SDK modules used in this codebase; extend when a new system framework is adopted. +SYSTEM_MODULES = { + "Foundation", "FoundationNetworking", "SwiftUI", "AppKit", "Cocoa", "Combine", + "CoreGraphics", "OSLog", "os", "PDFKit", "QuickLookUI", "RegexBuilder", + "UniformTypeIdentifiers", "UserNotifications", "AVKit", "CryptoKit", "Security", + "Swift", "XCTest", "Testing", +} + +UI_FRAMEWORKS = {"SwiftUI", "AppKit", "Cocoa"} + +# Rule 4: the single target permitted to opt out of Swift 6. +SWIFT5_ALLOWED = {"CEEditor"} + +IMPORT_RE = re.compile( + r"^\s*(?:@[\w()]+\s+)?import\s+(?:struct\s+|class\s+|enum\s+|func\s+|var\s+)?([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, +) +TARGET_START_RE = re.compile(r"\.(target|testTarget)\(\s*name:\s*\"([^\"]+)\"") + + +def balanced_block(text, open_index): + """Return text from the '(' at open_index through its matching ')'.""" + depth = 0 + for i in range(open_index, len(text)): + if text[i] == "(": + depth += 1 + elif text[i] == ")": + depth -= 1 + if depth == 0: + return text[open_index:i + 1] + raise ValueError("unbalanced parentheses in manifest") + + +def parse_targets(text): + """Map target name -> {kind, deps, swift_modes} from the manifest.""" + targets = {} + for match in TARGET_START_RE.finditer(text): + kind, name = match.group(1), match.group(2) + paren = text.index("(", match.start()) + block = balanced_block(text, paren) + deps = set(re.findall(r'"([A-Za-z][\w.\-]*)"', _dependencies_slice(block))) + deps |= set(re.findall(r'\.product\(\s*name:\s*"([^"]+)"', block)) + deps.discard(name) + targets[name] = { + "kind": kind, + "deps": deps, + "swift_modes": set(re.findall(r"\.swiftLanguageMode\(\.(\w+)\)", block)), + } + return targets + + +def _dependencies_slice(block): + """The text inside this target's `dependencies: [...]`, or '' if absent.""" + match = re.search(r"dependencies:\s*\[", block) + if not match: + return "" + start = block.index("[", match.start()) + depth = 0 + for i in range(start, len(block)): + if block[i] == "[": + depth += 1 + elif block[i] == "]": + depth -= 1 + if depth == 0: + return block[start:i + 1] + return "" + + +def main() -> int: + if not MANIFEST.exists(): + print(f"Package audit FAILED: no manifest at {MANIFEST}") + return 1 + + text = MANIFEST.read_text() + targets = parse_targets(text) + library_targets = {n for n, t in targets.items() if t["kind"] == "target"} + failures = [] + + # --- Rule 1: CodeEditCore purity (manifest half) --- + if targets.get("CodeEditCore", {}).get("deps"): + failures.append( + "CodeEditCore must have zero dependencies " + f"(found {sorted(targets['CodeEditCore']['deps'])})" + ) + + # --- Rule 2: CodeEditUI purity --- + ui_local = targets.get("CodeEditUI", {}).get("deps", set()) & library_targets + if ui_local: + failures.append( + "CodeEditUI may not depend on local targets — CodeEditSymbols only " + f"(found {sorted(ui_local)})" + ) + + # --- Rule 4: language-mode assertion --- + for name, target in sorted(targets.items()): + if "v5" in target["swift_modes"] and name not in SWIFT5_ALLOWED: + failures.append( + f"{name}: only {sorted(SWIFT5_ALLOWED)} may declare .swiftLanguageMode(.v5) — " + "every other target must stay on Swift 6" + ) + + # --- Rule 3: import honesty, plus Rule 1's no-UI half --- + for name, target in sorted(targets.items()): + source_dir = PACKAGE / ("Tests" if target["kind"] == "testTarget" else "Sources") / name + if not source_dir.is_dir(): + failures.append(f"{name}: declared in the manifest but {source_dir} does not exist") + continue + system_modules = SYSTEM_MODULES - UI_FRAMEWORKS if name == "CodeEditCore" else SYSTEM_MODULES + allowed = target["deps"] | system_modules | {name} + for swift in sorted(source_dir.rglob("*.swift")): + rel = swift.relative_to(REPO) + for module in sorted(set(IMPORT_RE.findall(swift.read_text()))): + if module not in allowed: + failures.append(f"{rel}: import {module} is not declared for target {name}") + if name == "CodeEditCore" and module in UI_FRAMEWORKS: + failures.append(f"{rel}: {module} import violates the CodeEditCore no-UI rule") + + if failures: + print(f"Package audit FAILED ({len(failures)} violations):") + for failure in failures: + print(f" {failure}") + return 1 + + # --- Norm (informational only): hub heuristic --- + dependents = {n: 0 for n in targets} + for target in targets.values(): + for dep in target["deps"]: + if dep in dependents: + dependents[dep] += 1 + hubs = sorted( + n for n, t in targets.items() + if t["kind"] == "target" + and dependents[n] >= 3 + and len(t["deps"] & library_targets) >= 3 + ) + if hubs: + print(f"Note — hub targets under review (>=3 dependents and >=3 local deps): {hubs}") + + print(f"Package audit passed ({len(library_targets)} library targets).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_app.sh b/.github/scripts/test_app.sh index cd5354c4a1..7ca60d576c 100755 --- a/.github/scripts/test_app.sh +++ b/.github/scripts/test_app.sh @@ -20,6 +20,7 @@ export LC_CTYPE=en_US.UTF-8 # - is-ci: include test results in output set -o pipefail && arch -"${ARCH}" xcodebuild \ + -workspace CodeEdit.xcworkspace \ -scheme CodeEdit \ -destination "platform=OS X,arch=${ARCH}" \ -skipPackagePluginValidation \ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6188582359..1a9b7ac65c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,3 +11,5 @@ jobs: - uses: actions/checkout@v3 - name: GitHub Action for SwiftLint run: swiftlint --reporter github-actions-logging --strict + - name: Audit package imports and tier rules + run: python3 .github/scripts/audit_package_imports.py diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 2bfce92e9a..e335f7ff0e 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -48,7 +48,7 @@ jobs: - name: Build CodeEdit env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: xcodebuild -scheme CodeEdit -configuration Pre -derivedDataPath "$RUNNER_TEMP/DerivedData" -archivePath "$RUNNER_TEMP/CodeEdit.xcarchive" -skipPackagePluginValidation DEVELOPMENT_TEAM=$APPLE_TEAM_ID archive | xcpretty + run: xcodebuild -workspace CodeEdit.xcworkspace -scheme CodeEdit -configuration Pre -derivedDataPath "$RUNNER_TEMP/DerivedData" -archivePath "$RUNNER_TEMP/CodeEdit.xcarchive" -skipPackagePluginValidation DEVELOPMENT_TEAM=$APPLE_TEAM_ID archive | xcpretty ############################ # Sign diff --git a/.gitignore b/.gitignore index cc4bf648f6..f1ae65a228 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,11 @@ playground.xcworkspace .build/ +# Resolved file for the local CodeEditModules package — the workspace's +# shared Package.resolved is authoritative; this is Xcode/SwiftPM-generated noise. +CodeEditModules/Package.resolved +CodeEditModules/.swiftpm/ + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However @@ -96,3 +101,8 @@ iOSInjectionProject/ .codeedit .idea .vscode + +# The .xcodeproj's implicit workspace is not a build entry point — CodeEdit.xcworkspace is. +# Xcode regenerates this lockfile whenever the bare project is opened; it drifts and misleads. +# The app bundles the workspace's lockfile as its Acknowledgements data source. +CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/.swiftlint.yml b/.swiftlint.yml index 1aa8fd0d3a..a486436794 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -15,7 +15,8 @@ identifier_name: # paths to ignore during linting. excluded: - - CodeEditModules/.build # Where Swift Package Manager checks out dependency sources + - "**/.build" # SwiftPM dependency checkouts (inside CodeEditModules/) + - "**/.swiftpm" - DerivedData opt_in_rules: @@ -38,3 +39,15 @@ custom_rules: regex: "\t" message: "Prefer spaces for indents over tabs. See Xcode setting: 'Text Editing' -> 'Indentation'" severity: warning + no_ui_in_core: + included: "CodeEditModules/Sources/CodeEditCore/.*\\.swift" + name: "CodeEditCore charter" + regex: "^import (SwiftUI|AppKit|Cocoa)$" + message: "CodeEditCore must stay UI-free — move UI-coupled code to CodeEditUI or the owning feature (see docs/ARCHITECTURE.md)" + severity: error + ui_package_purity: + included: "CodeEditModules/Sources/CodeEditUI/.*\\.swift" + name: "CodeEditUI charter" + regex: "^import (CodeEditCore|CodeEditDocument|CodeEditSettings|CEEditor|CESearch|CENotifications|CELSP|CESourceControl|CETerminal|ShellClient|CEWorkspaceFileManager)$" + message: "CodeEditUI depends on CodeEditSymbols only — presentation atoms must not know about models or features (see docs/ARCHITECTURE.md)" + severity: error diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000000..1174262253 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,435 @@ +# CodeEdit Architecture Guide + +This guide explains how the codebase is organized and, most importantly, **where new code goes**. +The CI enforces the rules described here (see [Enforcement](#enforcement)), so reading this before you add files will save you a failed check. + +## Package topology + +The workspace contains one app project and one local Swift package holding 12 library targets and 6 test targets: + +``` +CodeEdit.xcworkspace +├── CodeEdit.xcodeproj — app shell + composition UI +└── CodeEditModules/ + ├── Package.swift — the entire local dependency graph, in one file + ├── Sources/ + │ ├── CodeEditCore — domain types, EventBus, command interfaces (no UI, zero deps) + │ ├── CodeEditUI — shared presentation atoms (→ CodeEditSymbols only) + │ ├── CodeEditSettings — settings seam + store (UI pages stay app-side) + │ ├── CodeEditDocument — CodeFileDocument + editor-framework bridging protocols + │ │ (consumed only by CEEditor and CELSP) + │ ├── ShellClient — Process adapter (app-linked; no package-internal consumer) + │ ├── CEWorkspaceFileManager — FileManager + FSEvents workspace tree (app-linked, ditto) + │ └── CEEditor, CESearch, CENotifications, CELSP, CESourceControl, CETerminal + │ — one target per feature + └── Tests/ — CodeEditCoreTests, CodeEditSettingsTests, + CodeEditUIUnitTests, CESearchTests, CELSPTests, + CESourceControlTests +``` + +Each library target publishes a like-named `.library` product, and the app target links the ones it needs. +Inside the manifest, targets reference each other by bare name, so the whole graph is legible in a single file, which is the point. +See [History](#history-why-the-2022-module-split-failed) for what the previous arrangement cost. + +Naming: `CodeEdit*` marks substrate peer-named with the external CodeEdit libraries (`CodeEditSourceEditor`, `CodeEditSymbols`, …); `CE*` marks app-internal feature contexts, peer-named with the `CE*` domain types. +Apply `CE` only where the bare name would collide with a stdlib/SwiftUI/AppKit/vendor type, it is a collision-avoider, not a namespace. + +Every target builds with Swift 6 strict concurrency **except `CEEditor`**, which declares `.swiftLanguageMode(.v5)` and is the sole exception. +The app target is still Swift 5, write new app-side code Swift-6-ready, and don't add `@MainActor` to app types whose callers aren't isolated (it cascades). + +## Design principles + +Verified against the codebase on 2026-08-21. +Each is a claim about how the code is arranged today, not an aspiration. + +1. **Dependencies point one way.** + App shell, then features, then services, then foundation, with dependency-free `CodeEditCore` at the base. + Acyclic, every edge pointing at something more fundamental, and no peer edges between features: there are currently zero feature-to-feature imports. + Enforced by package boundaries, not convention. +2. **Every boundary is a protocol.** + Anything performing I/O or reached across a feature boundary is protocol-backed and substitutable in tests: `ShellClientProtocol`, `GitClientProtocol`, `LSPServiceProtocol`, `RegistryManaging`, `KeybindingManaging`, `NotificationManaging`, `WorkspaceWindowManaging`, `SettingsAccessing`. +3. **Features are islands.** + No feature imports another. + Cross-feature interaction happens only through events, command interfaces, and shared substrate. +4. **State has one owner.** + See [State ownership](#state-ownership) below. +5. **Facts are broadcast; commands have one handler.** + The mechanism follows the intent, see [Communication rules](#communication-rules). +6. **Native first, framework-light.** + SwiftUI and AppKit plus one local package. + No meta-frameworks: there is no Composable Architecture or equivalent anywhere in the tree. + The architecture is conventions plus compile-time boundaries. +7. **Testability is the acceptance test.** + If a feature cannot be tested without building the whole app graph, the architecture has failed at that spot. +8. **Idiomatic by default.** + Follow standard Swift and Xcode conventions rather than inventing project-specific layouts. + Local code lives in one multi-target package, `CodeEditModules`, whose whole dependency graph is legible in a single manifest. + The Swift API Design Guidelines and the repo's SwiftLint rules govern code. + When a choice is unclear, prefer the community-idiomatic option over a bespoke one. + +### State ownership + +**Domain state lives in the object that owns the domain**: `SourceControlManager` for git status, `TaskManager` for running tasks, `LSPService` for language servers, `CEWorkspaceFileManager` for the file tree. +Note that most of those live in the *feature* that owns them (`CESourceControl`, `CETerminal`, `CELSP`), not in a service target; only the file tree does. +Such an object never holds UI state: `SourceControlManager` does not know that sheets exist. + +**Presentation state lives in a view-state object**, which is the presentation-state split described under [Communication rules](#communication-rules). +Sheet and popover flags, selection, expansion, scroll targets. + +**Views own only ephemera**, via `@State`: hover, focus, in-progress text. + +Data flows down through observation, actions flow up through method calls and operation doers, and cross-feature effects travel only via events and command interfaces. + +### Deliberately not chosen (or not yet) + +- **The `@Observable` macro.** + Not adopted. + Every observable model is `ObservableObject` with `@Published` (48 conformances); the only two mentions of `@Observable` in the tree are TODO comments. + Note that the deployment target *is* macOS 14 in both `project.pbxproj` and `Package.swift`, so the blocker is team agreement on that minimum rather than the code, and adopting `@Observable` would make the 14+ floor irreversible. +- **Swapping the `EventBus` off Combine cheaply.** + It is not cheap, contrary to an earlier claim in this guide. + `subscribe(_:)` returns `AnyPublisher`, so Combine is in the bus's public signature rather than hidden behind it, and all five subscribers use `sink` and `AnyCancellable`. + An `AsyncStream` backend would change the return type and rewrite every call site. + The bus is still regarded as a stopgap, with the platform's typed notifications as its natural replacement, but treat that as a migration rather than a substitution. +- **DI frameworks.** + The explicit `AppDependencies` composition root is the whole mechanism, and there is no container anywhere in the tree. + Dependencies are visible in initialisers and resolution failures are compile errors. +- **Architecture frameworks such as TCA.** + The cost, meaning a learning curve for a community codebase, framework lock-in, and fighting AppKit interop, outweighs the benefit. + Conventions plus package boundaries achieve the same testability. +- **Per-feature interface micro-packages.** + Interface and implementation splits per feature are overkill at this scale. + `CodeEditCore` carries the interfaces, 12 files under `Infrastructure/`. + +## History: Why the 2022 module split failed + +The project already tried a single multi-target `CodeEditModules` package. +It was deleted on 2022-12-03 (commit `4858de16`) after repeated cyclic-dependency problems, and everything moved into the app target. +**The cycles were not caused by the packaging shape.** The 2022 manifest's own edges explain them: + +| Cause | Evidence in the 2022 manifest | +|---|---| +| **No kernel existed** | No framework-free contracts target. Shared types lived in whichever module happened to own them, so "A and B both need X" was only expressible as a feature→feature edge. | +| **Domain depended on UI** | `WorkspaceClient → TabBar` | +| **Shared UI depended on domain** | `CodeEditUI → WorkspaceClient, Git` | +| **A god-module hub** | `AppPreferences → CodeEditUI, Git, Keybindings, CodeEditUtils, Sparkle, CodeEditTextView`, itself depended on by half the tree | + +With the bottom of the graph pointing up into the top, cycles were the steady state rather than an accident. +The same manifest split across eleven separate packages fails identically, SwiftPM refuses to resolve a cyclic graph either way. + +**`CodeEditCore` is the fix, and it now exists.** Zero dependencies, framework-free, holding domain values, the typed `EventBus`, and the cross-feature command interfaces. +Every "A and B both need X" now resolves *downward*. +Both 2022 killers are structurally impossible today: the domain lives in `CodeEditCore`, which may import nothing, and `CodeEditUI → Git` is blocked by the `CodeEditUI` purity rule. + +### Cycle-resolution playbook + +Detection was never the problem, SwiftPM refuses a cyclic target graph as a hard error. +The 2022 failure was that detection had no accompanying *resolution* technique, so the exit taken was to collapse everything into the app target. +When you hit a cycle, the legal moves, in preference order: + +1. **Push the shared thing down to `CodeEditCore`** — a protocol, an event, or a value type. + This is what `EventBus` and the command interfaces (`WorkspaceNavigator` `TasksConfigurationProviding`, …) are for. + The default answer. +2. **Push the coordination up to the app target** — the app may depend on everything. + Two leaf features never need to know each other if a doer wires them (`WorkspaceOpener`, `DocumentOpener`). +3. **Merge the two targets** — if A and B genuinely will not separate, the boundary was drawn wrong. + Merging is a correct outcome, not a defeat; in one package it is a folder move plus a three-line manifest edit. + +Never resolve a cycle by moving code back into the app target. +That is what happened in 2022 and it cost four years of enforced boundaries. + +## Rules + +Three checks are enforced in CI. +Each one blocks a specific failure documented in [History](#history-why-the-2022-module-split-failed), none is enforced on principle alone. + +1. **`CodeEditCore` purity.** Zero dependencies, local or external. No `SwiftUI`, `AppKit`, or `Cocoa` import. + Blocks 2022's `WorkspaceClient → TabBar`. + The two constraints earn their keep separately: **zero local dependencies** is the acyclicity guarantee, because it makes Core a sink, so every "A and B both need X" resolves downward, which is the direct fix for the "no kernel existed" failure above. + **No UI frameworks** keeps the placement question answerable. + Without it, Core becomes the place everything shared goes, which is what `AppPreferences` was and one of the two documented causes of the 2022 collapse. + + The friction this produces is usually the rule working, not obstructing you. + Four worked examples, and the `NSFont.Weight` counter-example people cite, are in [docs/architecture-decisions.md](docs/architecture-decisions.md). +2. **`CodeEditUI` purity.** No local target dependencies; external `CodeEditSymbols` only. + Blocks 2022's `CodeEditUI → Git`. + This is why `FileIcon` is keyed on `URL` rather than on a domain type: a deliberate consequence, not an accident. +3. **Import honesty.** Every `import` in a target's sources must be declared in that target's manifest dependencies. + Xcode workspace builds share one build directory, so an undeclared import of a sibling compiles fine and nothing else catches it. + For the 7 targets that transitively need `CodeEditSymbols`, a standalone `swift build` is not available as a backstop either (see [Enforcement](#enforcement)), so this check is their only defence, not a redundant one. + +Plus one assertion: **only `CEEditor` may declare `.swiftLanguageMode(.v5)`.** Every other target inherits Swift 6 from the package's tools version. +A target silently dropping to Swift 5 would lose strict-concurrency enforcement without anything failing. + +### Norms (review-time, not gates) + +**Prefer features to be leaves.** Nothing should depend on a feature target. +When an edge between two features is genuinely needed, try the three [cycle-resolution moves](#cycle-resolution-playbook) first, then declare the edge in the manifest where it is visible to everyone. +Acyclicity itself needs no rule, SwiftPM enforces it. + +**Keep I/O out of Core.** A norm, not a gate. +It is worth being precise about, because the guide previously implied it was enforced. +It is not: the SwiftLint rule forbids `SwiftUI`/`AppKit`/`Cocoa` and nothing more, and `Foundation`, which Core needs for `URL`, `Data` and `Codable`, and which 47 of its files import, *is* the I/O surface, so an import check cannot express this. + +The reason to keep it out anyway is concrete, not decorative. +`CodeEditCoreTests` is five files with zero use of `FileManager`, `temporaryDirectory` or `Data(contentsOf:)`: Core's tests need no filesystem, no temp directories and no cleanup. +And I/O already has a designated home, since rule 4 of [Where does my code go?](#where-does-my-code-go) sends services to their own target, which is what `CEWorkspaceFileManager` and `ShellClient` are. + +See [docs/architecture-decisions.md](docs/architecture-decisions.md) for what this norm has actually prevented, and for why `ShellClient`, `CEWorkspaceFileManager`, `CodeEditDocument` and `CELSP` each stay separate targets. + + +**Known exception, recorded rather than pretended away:** `CEWorkspaceFile` exposes `static let fileManager = FileManager.default` and uses it for `isEmptyFolder` and `doesExist`. +Those are filesystem reads from a domain type. +Moving them onto the file-manager service is the pure fix; it is not worth it today against 294 references. +What was worth fixing, and has been, is code *outside* Core borrowing that static to mutate the filesystem, a write routed through the domain layer. +There is now no such caller. + +**Hub heuristic.** Any target both depended on by three or more others *and* itself depending on three or more is a hub under review. +2022's `AppPreferences` was exactly this and would have been flagged years before it became fatal. `CodeEditSettings` is the current watch item: five dependents as of the panel-contributions work (`CEEditor`, `CELSP`, `CESearch`, `CESourceControl`, `CETerminal`, up from four; `CESearch` joined when `FindNavigatorContribution` started reading its own settings instead of taking them from an app-side wrapper) but only one dependency (`CodeEditCore`), so it stays a well-formed shared substrate rather than a hub, and it imports `AppKit` in 1 file and `SwiftUI` in 4, down from 7 once the theme moved to Core and the colour conversion to `CodeEditUI` (re-measured 2026-08-16, not carried forward). + +## Where does my code go? + +Work through these in order; the first match wins. + +1. **A new user-facing feature?** → A new target at `CodeEditModules/Sources/CE` (see the [recipe](#creating-a-new-feature-target)). + Features start as targets; the app target is not the default. + Exception: *shell chrome* that composes multiple features around the concrete `Workspace` hub (navigator/inspector/utility areas, the status bar) stays app-side, because its interface would effectively be "the whole app". + **This exemption is about the panel, not any one tab inside it.** `NavigatorAreaView` hosts a tab bar over many features' tabs and has no single owner, so it stays app-side; a *tab* is one feature's own UI and belongs in that feature's package. + `CESearch` owns `FindNavigatorContribution` for exactly this reason (see [Panel tab contributions](#panel-tab-contributions)). + The only app-side tabs that stay are the ones with no owning package to move to: `ProjectNavigatorContribution`, `FileInspectorContribution`, `InternalDevelopmentInspectorContribution`, `DebugConsoleUtilityContribution` and `OutputUtilityContribution`. + `TerminalUtilityContribution` is the one expected to move, to `CETerminal`. + None of them stays because it is a tab. +2. **A type, protocol, event, or command interface needed by two or more features?** → `CodeEditModules/Sources/CodeEditCore`, *if* it passes the charter (no UI imports, no external dependencies). + Events (facts, e.g. `TaskNotificationEvent`) and command interfaces (requests with exactly one handler, e.g. `WorkspaceNavigator`) always live here. +3. **A reusable view, style, or view modifier with no feature semantics?** → `CodeEditModules/Sources/CodeEditUI`. +4. **A service that performs I/O and has no UI?** → A new target at `CodeEditModules/Sources/` (Core-only dependencies, its own library product, the app links it directly). +5. **Cross-service orchestration?** → A doer-style role-noun class in the feature that owns the operation (`WorkspaceOpener`, `FileMover`, `RepositoryCloner`, following the `NSFileCoordinator` naming idiom). + One doer per operation that touches more than one service; dependencies arrive via the initializer. +6. **Everything else: composition, adapters, menu commands, settings pages, app lifecycle?** → The app target, inside the owning feature folder. + +### The consumer-count litmus + +The most common placement mistake is moving something "up" because it *looks* generic. +Don't judge generic-ness. +**Count consumers**: + +- A helper with **one** consumer lives next to that consumer, even if it looks general-purpose. +- It moves to `CodeEditCore` only when a **second consumer actually appears** *and* it passes the zero-dependency charter. +- The rule runs **both ways**. + A type already in `CodeEditCore` that turns out to have a single consumer moves *out*, into that consumer's package. + Framework-freedom is necessary but not sufficient: Core is a shared kernel, and every type in it that isn't actually shared is coupling every context pays for and nobody uses. +- The exception is **deliberate contracts**: events, command interfaces, and read-models stay in Core even with one publisher or one implementor today, because being a seam is their entire purpose. + `FileEditorOverrideValues` stays for the same reason: it is the payload of Core's `FileEditorOverrides` protocol. + +Applying this in July 2026 evicted five types: the registry install cluster (`InstallationMethod`, `PackageSource`, `PackageManagerType`, `RegistryManagerError`) to CELSP, and `GitBranchesGroup` to CESourceControl. + +Worked example: fuzzy matching earned its place in CodeEditCore, with three consumers across two features (Open Quickly, Theme settings, Language Servers), but its concurrency helper depended on CollectionConcurrencyKit, which Core's zero-dependency charter forbids. +The fix was to rewrite the helper over `withTaskGroup` (about ten lines) rather than admit the dependency, so `Domain/FuzzyMatching/` is now dependency-free. +**Dependency honesty beats tidiness**: never add a dependency to a foundation package to make a move possible. +Rewrite the helper, or mirror it locally, instead. + +## Folder conventions + +Grouping is **by purpose, never by kind**. +There are no `Models/`, `Views/`, `ViewModels/`, `Services/`, `Protocols/`, `UseCases/` or `Extensions/` folders. + +- Group by sub-feature or subject (`ProjectNavigator/`, `Restoration/`, `TabBar/`, `Shell/`), and if you cannot name the group without saying what kind of type it holds, it is not a group. + A conformance file belongs beside the protocol it satisfies, and environment keys belong with their subject rather than in an `Environment/` folder. +- A feature with roughly ten files or fewer stays flat. +- Shell and entry views, plus the feature's primary models, sit at the feature root. +- Single-consumer helpers live next to their consumer. +- `Utils/` is closed. Place a new utility next to its consumer, and argue the case if you think it belongs in `Utils/`. + +**Worked example.** `CEEditor` had 55 files under `Models/`, `Views/` and `UseCases/`. +They became `Editor/`, `Layout/`, `FileViews/`, `TabBar/` (with `Tabs/` and `Tab/`), `JumpBar/`, `Documents/`, `Restoration/`, `Theme/` and `Adapters/`, as pure renames with no content change. +Two placements are worth copying: `CEWorkspaceFile+Editor` sits in `TabBar/Tab/` beside the `EditorTabRepresentable` protocol it conforms to, and `UndoManagerRegistry` sits in `Documents/` rather than `Restoration/`, because it performs no saving. + +**Two targets are stated exceptions.** +`CodeEditCore` keeps its `Domain/` and `Infrastructure/` split, because there the layer *is* the purpose. +`CodeEditUI` keeps `Styles/`, `Views/` and `EnvironmentKeys/`, because it is a component library with no feature semantics by charter, so kind is the subject a consumer browses by. +Do not "fix" either. + +## Communication rules + +- **Feature packages never import each other.** Cross-feature signaling is typed: + - **Facts** (something happened) → an event on the `EventBus` in CodeEditCore. + - **Requests** (do something, exactly one rightful handler) → a command interface in CodeEditCore, implemented by an app-side adapter. +- No custom `Notification.Name`s. + `NotificationCenter` is only used to observe platform notifications (NSWindow, NSApplication, NSMenu). +- **No DI container.** `AppDependencies` is the app-scope composition root; objects receive dependencies through initializers, SwiftUI views through environment keys (`appServices(_:)`). + Only composition roots may hold the whole `AppDependencies`. + A container (ask for a type, get an instance) was removed deliberately: it hides who owns a thing and how long it lives, which is the question this section exists to answer. +- **Scope determines owner; nothing scoped is reached ambiently.** See [Scopes and ownership](#scopes-and-ownership) below. + A `static shared` is legitimate only where the platform constructs the object and no initialiser parameter is available. + Today that is `CodeFileDocument`, which is why `delegateProvider` is a static closure set at launch. + Eight other singletons remain, listed there as known exceptions. +- No SwiftUI view observes a service directly. + Services expose a concrete view-state object (the presentation-state split), and views issue commands through protocol-typed environment keys. + +### Scopes and ownership + +Four lifetimes exist. +Each has an owner, and a type belongs to the narrowest one that fits. + +| Scope | Owner | Examples | +| --- | --- | --- | +| Process | `AppDependencies` | `eventBus`, `lspService`, `settingsStore`, `workspaceWindowManager` | +| Workspace | `Workspace` | `editorManager`, `workspaceFileManager`, `sourceControlManager`, `taskManager` | +| Window | `CodeEditWindowController` | `utilityAreaModel`, `statusBarViewModel`, `notificationPanel`, `openQuicklyViewModel` | +| Document | `CodeFileDocument` | per-file editing state | + +Window scope is not a subdivision of workspace scope: two windows may show one project, and their utility areas, status bars and palettes must not be shared. +That is why window-UI state lives on `CodeEditWindowController` and not on `Workspace`. + +Document scope is where the platform pushes back. +`NSDocument` subclasses are created by the document architecture, not by us, so no initialiser parameter is available, hence `CodeFileDocument.delegateProvider`, a static closure set at launch. +That is the shape of a legitimate exception: the platform owns construction. + +**Note on idiom:** "no singletons" is not the goal and never was. +Apple's own frameworks are full of them (`NSApplication.shared`, `FileManager.default`, `NSDocumentController.shared`). +What was removed was a *container*. +A `shared` is a problem here only when it gives ambient access to something whose lifetime is narrower than the process, or when it hides an owner that could hold it. + +Eight singletons remain, none load-bearing. +They are listed with the scope each actually has, plus the one that is a scope error rather than a leftover, in [docs/architecture-decisions.md](docs/architecture-decisions.md). + +## Panel tab contributions + +The navigator, inspector and utility area do not switch on closed enums. +Each panel is a `[any WorkspacePanelContribution]` assembled by one function per panel in `CodeEdit/WorkspaceWindow/WorkspacePanel/PanelContributions.swift`, so a first-party tab, app shell chrome and an extension's tab are the same kind of value. + +**This is the pattern any future seam should follow**, and there will be others (settings pages, menu commands, the extension contract): + +- The feature vends its own contribution from its own package, reading whatever it needs directly. +- The app assembles the list at a composition root. +- Whatever the contribution needs and its package cannot see becomes a **required initialiser parameter**, never a defaulted one, so a missing injection is a compile error rather than a silently empty tab. +- Vendor vocabulary stays in one adapter: `ExtensionPanelContribution` is the only app file outside `AuxiliaryWindows/Extensions/` permitted to name `AppExtensionIdentity` or `ResolvedSidebar`. + +`WorkspacePanelContribution` lives in `CodeEditUI` because it needs SwiftUI and nothing else; it cannot live in `CodeEditCore`, which forbids UI imports. +Specifics of this seam, including `bottomView` and selection reconciliation, are documented on the protocol itself and in [docs/architecture-decisions.md](docs/architecture-decisions.md). + +## Reading and writing settings + +Feature packages reach settings through the **settings seam** in `CodeEditSettings` (`Store/SettingsValue.swift`, `Store/SettingsAccessing.swift`), never through a singleton and never by naming the app-wide `SettingsData` aggregate. +Three roles, pick by consumer kind: + +| Consumer | Use | Why | +| --- | --- | --- | +| SwiftUI view | `@SettingsValue(TerminalSettings.self, \.cursorBlink)` | Observes the injected `PersistentSettingsStore`; `$`-projects a `Binding` for `Toggle`/`TextField`. | +| Read-only object (managers, services) | `SettingsReading` by initializer | No environment outside a view; narrow protocol makes read-only visible at the call site. | +| Object that also writes | `SettingsAccessing` by initializer | The read+write half; every `SettingsAccessing` satisfies `SettingsReading`. | + +Access is **section-granular**: `value(_:)`/`setValue(_:)` deal in whole `SettingsSection` values, so a caller changing one field reads its section, mutates it and writes it back. + +- **`@AppSettings` is app-target only.** It observes the same store as `SettingsValue`, but addresses a section field through the app-wide `SettingsData` façade instead of naming one section directly. + There is no `Settings.shared` singleton any more, `PersistentSettingsStore` (owned by `AppDependencies`) is the concrete store, injected like everything else. + `@AppSettings` is what 24 app-target files still use (40 declarations, re-counted 2026-08-16); feature packages must not use it, and new app-target code should prefer the seam. +- **Neither wrapper works in a `Commands` conformer.** `.commands { }` attaches beside a scene's content, not inside it, so nothing guarantees the environment `SettingsSceneInjector` supplies reaches menu-bar code. + `CodeEditCommands`/`ViewCommands` are handed `PersistentSettingsStore` by initializer and `@ObservedObject` it, reads, writes and menu invalidation all stop depending on undocumented behaviour. +- **The environment does not cross an `NSHostingView`/`NSHostingController` boundary.** A new standalone hosting root must be wrapped in `SettingsInjector` (or `SettingsSceneInjector` for a scene), or every `@SettingsValue` under it **traps**. + That is deliberate: this replaced a pair of environment keys whose failure modes were silent, a subtree given neither read plausible defaults and discarded writes, and a subtree given the value but not the separate `Int` invalidation key read correctly and never re-rendered. + `@EnvironmentObject` makes both unrepresentable, because SwiftUI subscribes to the store itself. + Those two injectors are the only places the store is injected, which is what makes the coverage question answerable by grep rather than by reachability analysis. +- **A protocol double cannot be substituted into a view.** `@EnvironmentObject` cannot carry an existential, so a view-level test injects a real `PersistentSettingsStore` on a temporary file. + The protocol seam still applies to every initializer-injected consumer, which is where `RecordingSettingsStore` and `SnapshotSettingsReader` are used. +- **`DefaultSettingsReader` is not the environment's fallback any more** — there is no fallback. + It survives as the stand-in four singletons (`ThemeModel`, `FeedbackModel`, `SearchSettingsModel`, `HistoryInspectorModel`) hold between construction and `configure(_:)`. + It still `assertionFailure`s outside previews, since reaching it means a real store never arrived. +- **`PersistentSettingsStore` is the concrete store.** Section-keyed storage, owned by `AppDependencies` (no `shared`), driving the same throttled save pipeline `Settings.shared` used to own. + Sections nothing here decodes are held verbatim and re-emitted on save, so a disabled extension's configuration survives. + The same holds for a section that is present but *undecodable*: it reads as defaults but is re-emitted unchanged, and the one write that would replace it is announced through `SettingsStore.willReplaceUndecodableSection` so the file is copied to `settings.json.corrupt-` first. + +### Where a settings section lives + +A section lives with **its owner**: + +| Readers | Home | Examples | +| --- | --- | --- | +| Exactly one feature package | that package | `TerminalSettings` → `CETerminal`, `LanguageServerSettings` → `CELSP`, `SourceControlSettings`/`AccountsSettings` → `CESourceControl` | +| More than one module, or only the app | `CodeEditSettings` | `TextEditing`, `Theme`, `General`, `Navigation`, `Developer`, `Search`, `Keybindings` | + +**Never `CodeEditCore` or `CodeEditUI`.** A `Codable` config bag has no natural boundary and accretes. +That is precisely how Core became `AppPreferences` the first time, and Core's purity rationale is that the placement question stays answerable. +`CodeEditUI` is excluded mechanically: it may not depend on a local target, so it cannot see settings types at all. + +**Migration trigger:** when a section's readers collapse to a single feature, it moves with that feature. +That is how the four package-owned sections got where they are. + +App-only sections (`SearchSettings`, `KeybindingsSettings`) stay in `CodeEditSettings` rather than moving app-side: `SettingsFormatTests` guards the on-disk format for every section in one place using `Bundle.module` fixtures, and splitting two sections into the app target would split that guard across two bundle mechanisms to satisfy a boundary nothing enforces. + +Two field-level misplacements are **recorded but not fixed**, because both keys live in users' `settings.json` and moving a field is a data migration rather than a refactor: `GeneralSettings.findNavigatorDetail` is read by `CESearch` (a feature-specific field in a shared section), and `SearchSettings.ignoreGlobPatterns` is wired to its settings page and persisted but never read by `CESearch`: the control works and has no effect, which is worse than dead code because nothing looks unused. + +## Creating a new feature target + +1. Create the folder `CodeEditModules/Sources/CE/` and add a target and product for it in `CodeEditModules/Package.swift`: + + ```swift + .library(name: "CE", targets: ["CE"]), + ``` + + ```swift + .target( + name: "CE", + dependencies: [ + "CodeEditCore", + "CodeEditUI" + ] + ), + ``` + +2. Link the product to the app: CodeEdit target → *General* → *Frameworks, Libraries, and Embedded Content* → add `CE`. +3. Remember the target builds with **Swift 6 strict concurrency**, types crossing actor boundaries need `Sendable`, and UI-bound classes are usually `@MainActor`. + (`CEEditor` is the sole exception, see [Rules](#rules).) +4. Known quirk: targets that depend on `CodeEditSymbols` build via Xcode/xcodebuild only; standalone `swift build` fails on its `Bundle.module` resolution. +5. Declare **every** module you import in the manifest. + The workspace's shared build directory makes undeclared imports of sibling targets compile by accident, CI will catch it (see below). + +## Enforcement + +Two tools enforce the three [Rules](#rules) above; both run on every PR: + +- **SwiftLint** (`swiftlint --strict`, config in `.swiftlint.yml`), includes custom rules that reject UI imports in CodeEditCore and model/feature imports in CodeEditUI. + These fire inside Xcode while you type. +- **The package audit** (`.github/scripts/audit_package_imports.py`), verifies every `import` in every target is declared in that target's manifest dependencies, and that the three [Rules](#rules) plus the language-mode assertion hold. + It exists because Xcode workspace builds share one build directory, so an undeclared import of a sibling target compiles fine locally. + For the 7 targets that transitively need `CodeEditSymbols` there is no standalone `swift build` to fall back on either (see the quirk above), so for most of the graph this audit is the only thing standing between a leaky import and `main`. + +Run both locally from the repo root: + +```bash +swiftlint lint --strict --quiet +python3 .github/scripts/audit_package_imports.py +``` + +`--strict` matters: without it SwiftLint reports violations as warnings and exits 0, so a local run looks clean and CI fails on the same tree. + +### Known weakness in the CodeEditUI charter (2026-08-05) + +Both checks constrain **local** targets only. +`ui_package_purity` lists sibling module names in a regex, and the audit script intersects the target's declared dependencies with the package's library targets. +So `CodeEditUI` is barred from importing `CodeEditCore`, a zero-dependency, pure-types package, while nothing stops it taking an arbitrary *external* dependency, up to and including a tree-sitter grammar bundle. +The rule as written is narrower than its own stated intent ("presentation atoms must not know about models or features") in one direction and far wider in the other. + +This surfaced while deduplicating `FileIcon`, which is presentation keyed by a file's identity. +It was designed to take a `URL` rather than a domain type, so it needs neither `CodeEditCore` nor the loophole, and its three custom colorsets moved into the package as resources, making `CodeEditUI` self-contained and letting its tests assert colours without an app host. +Treat the asymmetry as a known weakness, not a licence: adding an external dependency to `CodeEditUI` to sidestep the local-package rule would satisfy the letter of the charter and defeat its purpose. + +## Glossary + +Several words are overloaded in this codebase. +These are the intended meanings; prefer the qualified term whenever the bare one could be read two ways. + +| Term | Means | +| --- | --- | +| **Editor** (`Editor`) | One tab group inside a workspace window, a split pane with its own tab bar and selection. | +| **Editor instance** (`EditorInstance`) | One open file within an editor, holding that file's editing state. | +| **`EditorManager`** | The per-workspace owner of the editor layout (splits, the active editor). | +| **CEEditor** | The package containing the editor feature. | +| **CodeEditSourceEditor** | The external text-editing widget (a separate repository), not part of this codebase. | +| **Search** | *Project* search: find/replace across files, the index, query modes, the Find navigator. Lives in `CESearch`, which owns its whole model. | +| **Fuzzy matching** | Ranking candidates by match quality for typeahead (Open Quickly, theme and language-server pickers). A generic capability in CodeEditCore `Domain/FuzzyMatching/`. It does no searching; nothing here is named `*Search*`. | +| **Workspace** (`Workspace`) | The session aggregate for one open project: the project-scoped services and their lifetime. It owns lifecycle, *not* mutation routing, features mutate the sub-models they are handed. | +| **Workspace window** (`WorkspaceWindow/`) | The window and its chrome around a workspace: navigator, inspector, utility area, status bar. Window-UI state lives on `CodeEditWindowController`, not on `Workspace`. | +| **Document** (`CodeFileDocument`) | An open, editable file backed by NSDocument. Distinct from `CEWorkspaceFile` (a node in the file tree) and from the file on disk. | +| **Service** | A *target* holding an I/O adapter with no UI: `ShellClient`, `CEWorkspaceFileManager`. Do not use it loosely for "a long-lived object owning domain state", because most of those (`SourceControlManager`, `TaskManager`, `LSPService`) live in feature targets. | +| **Doer** | A role-noun class performing one operation that spans services (`WorkspaceOpener`, `FileMover`, `RepositoryCloner`), following the `NSFileCoordinator` naming idiom. Formerly called UseCases. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df215d282d..6395a8d4d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,18 @@ We also have a [troubleshooting guide](https://github.com/CodeEditApp/CodeEdit/w Please read our guide on [Code Style](https://github.com/CodeEditApp/CodeEdit/wiki/Code-Style) in our wiki. +## Architecture + +CodeEdit is a thin app target plus one local multi-target Swift package, `CodeEditModules`, +whose single `Package.swift` holds the entire local dependency graph. Before adding files, +please consult the decision tree in [ARCHITECTURE.md](ARCHITECTURE.md). It answers +"where does my code go?" in a few steps. The short version: a new feature starts as a new +target in `CodeEditModules/Package.swift`, `CodeEditCore` stays dependency-free and UI-free, +and `CodeEditUI` depends on `CodeEditSymbols` alone. CI enforces those as hard rules (SwiftLint +charter rules + a package import audit), so a misplaced file will fail checks. Features should +also prefer to be leaves that nothing else depends on, though that one is a review preference rather +than a gate, so declare any target-to-target edge in the manifest where reviewers can see it. + ## Pull Request Once you are happy with your changes, submit a `Pull Request`. diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2b2a39b04a..ad322dd3e2 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -11,12 +11,21 @@ 283BDCBD2972EEBD002AFF81 /* Package.resolved in Resources */ = {isa = PBXBuildFile; fileRef = 283BDCBC2972EEBD002AFF81 /* Package.resolved */; }; 284DC8512978BA2600BF2770 /* .all-contributorsrc in Resources */ = {isa = PBXBuildFile; fileRef = 284DC8502978BA2600BF2770 /* .all-contributorsrc */; }; 2BE487F428245162003F3F64 /* OpenWithCodeEdit.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2BE487EC28245162003F3F64 /* OpenWithCodeEdit.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 30818CB42D4E563900967860 /* ZIPFoundation */; }; 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */; }; 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64932C16CA9100CC8A9E /* LanguageClient */; }; - 583E529C29361BAB001AB554 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 583E529B29361BAB001AB554 /* SnapshotTesting */; }; - 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = 58F2EACE292FB2B0004A9BDE /* Documentation.docc */; }; + 5800E2F82FF843390085ECF1 /* CodeEditUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5800E2F72FF843390085ECF1 /* CodeEditUI */; }; + 588950C52FFA5C05004BE116 /* CESearch in Frameworks */ = {isa = PBXBuildFile; productRef = 588950C42FFA5C05004BE116 /* CESearch */; }; + 588957132FFA679E004BE116 /* ShellClient in Frameworks */ = {isa = PBXBuildFile; productRef = 588957122FFA679E004BE116 /* ShellClient */; }; + 5889571530CE12AB004BE116 /* CEWorkspaceFileManager in Frameworks */ = {isa = PBXBuildFile; productRef = 5889571430CE12AB004BE116 /* CEWorkspaceFileManager */; }; + 5889639E2FFA9A87004BE116 /* CENotifications in Frameworks */ = {isa = PBXBuildFile; productRef = 5889639D2FFA9A87004BE116 /* CENotifications */; }; + 58CE15A100000001004BE201 /* CELSP in Frameworks */ = {isa = PBXBuildFile; productRef = 58CE15A000000001004BE200 /* CELSP */; }; + 58CE50C200000002004BE302 /* CESourceControl in Frameworks */ = {isa = PBXBuildFile; productRef = 58CE50C100000002004BE301 /* CESourceControl */; }; + 58CE7E4200000003004BE402 /* CETerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 58CE7E4100000003004BE401 /* CETerminal */; }; + 58CFC49B2F8BE799009F4AA7 /* CodeEditCore in Frameworks */ = {isa = PBXBuildFile; productRef = 58CFC49A2F8BE799009F4AA7 /* CodeEditCore */; }; + 58ED10022FFB0001004BE116 /* CEEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 58ED10012FFB0001004BE116 /* CEEditor */; }; 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 58F2EB1D292FB954004A9BDE /* Sparkle */; }; + 5AD0C0DE2D00000000000001 /* CodeEditDocument in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD0C0DE2D00000000000002 /* CodeEditDocument */; }; + 5AD0C0DE2D00000000000011 /* CodeEditSettings in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD0C0DE2D00000000000012 /* CodeEditSettings */; }; 5E4485612DF600D9008BBE69 /* AboutWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 5E4485602DF600D9008BBE69 /* AboutWindow */; }; 5EACE6222DF4BF08005E08B8 /* WelcomeWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */; }; 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; }; @@ -108,11 +117,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 283BDCBC2972EEBD002AFF81 /* Package.resolved */ = {isa = PBXFileReference; lastKnownFileType = text; name = Package.resolved; path = CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; sourceTree = ""; }; + 283BDCBC2972EEBD002AFF81 /* Package.resolved */ = {isa = PBXFileReference; lastKnownFileType = text; name = Package.resolved; path = CodeEdit.xcworkspace/xcshareddata/swiftpm/Package.resolved; sourceTree = ""; }; 284DC8502978BA2600BF2770 /* .all-contributorsrc */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = ".all-contributorsrc"; sourceTree = ""; }; 2BE487EC28245162003F3F64 /* OpenWithCodeEdit.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenWithCodeEdit.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 589F3E342936185400E1A4DA /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; - 58F2EACE292FB2B0004A9BDE /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; 6C67413D2C44A28C00AABDF5 /* ProjectNavigatorViewController+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+DataSource.swift"; sourceTree = ""; }; 6C67413F2C44A2A200AABDF5 /* ProjectNavigatorViewController+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+Delegate.swift"; sourceTree = ""; }; 6C9619262C3F285C009733CE /* CodeEditTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CodeEditTestPlan.xctestplan; sourceTree = ""; }; @@ -145,7 +153,7 @@ isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; buildPhase = 6C6BD6FD29CD154900235D17 /* Embed ExtensionKit ExtensionPoint */; membershipExceptions = ( - Features/Extensions/codeedit.extension.appextensionpoint, + AuxiliaryWindows/Extensions/codeedit.extension.appextensionpoint, ); }; /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -170,20 +178,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */, 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, + 5800E2F82FF843390085ECF1 /* CodeEditUI in Frameworks */, + 5AD0C0DE2D00000000000001 /* CodeEditDocument in Frameworks */, + 5AD0C0DE2D00000000000011 /* CodeEditSettings in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */, 6CCF73D02E26DE3200B94F75 /* SwiftTerm in Frameworks */, + 5889639E2FFA9A87004BE116 /* CENotifications in Frameworks */, + 58ED10022FFB0001004BE116 /* CEEditor in Frameworks */, 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */, 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, + 58CFC49B2F8BE799009F4AA7 /* CodeEditCore in Frameworks */, 6CCF6DD32E26D48F00B94F75 /* SwiftTerm in Frameworks */, + 588957132FFA679E004BE116 /* ShellClient in Frameworks */, + 5889571530CE12AB004BE116 /* CEWorkspaceFileManager in Frameworks */, 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */, 5E4485612DF600D9008BBE69 /* AboutWindow in Frameworks */, 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */, @@ -195,6 +210,10 @@ 5EACE6222DF4BF08005E08B8 /* WelcomeWindow in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, + 588950C52FFA5C05004BE116 /* CESearch in Frameworks */, + 58CE15A100000001004BE201 /* CELSP in Frameworks */, + 58CE50C200000002004BE302 /* CESourceControl in Frameworks */, + 58CE7E4200000003004BE402 /* CETerminal in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */, @@ -205,7 +224,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 583E529C29361BAB001AB554 /* SnapshotTesting in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -244,7 +262,6 @@ B62454602D78A3D4009A86D1 /* CodeEditUITests */, B624544F2D78A3D3009A86D1 /* Configs */, B6FF04772B6C08AC002C2C78 /* DefaultThemes */, - 58F2EACE292FB2B0004A9BDE /* Documentation.docc */, B62454CD2D78A3D8009A86D1 /* OpenWithCodeEdit */, 6C9619262C3F285C009733CE /* CodeEditTestPlan.xctestplan */, 284DC8502978BA2600BF2770 /* .all-contributorsrc */, @@ -329,7 +346,6 @@ 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, - 30818CB42D4E563900967860 /* ZIPFoundation */, 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, @@ -337,6 +353,18 @@ 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */, 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */, + 58CFC49A2F8BE799009F4AA7 /* CodeEditCore */, + 5800E2F72FF843390085ECF1 /* CodeEditUI */, + 5AD0C0DE2D00000000000002 /* CodeEditDocument */, + 5AD0C0DE2D00000000000012 /* CodeEditSettings */, + 588950C42FFA5C05004BE116 /* CESearch */, + 58CE15A000000001004BE200 /* CELSP */, + 58CE50C100000002004BE301 /* CESourceControl */, + 58CE7E4100000003004BE401 /* CETerminal */, + 588957122FFA679E004BE116 /* ShellClient */, + 5889571430CE12AB004BE116 /* CEWorkspaceFileManager */, + 5889639D2FFA9A87004BE116 /* CENotifications */, + 58ED10012FFB0001004BE116 /* CEEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -360,7 +388,6 @@ ); name = CodeEditTests; packageProductDependencies = ( - 583E529B29361BAB001AB554 /* SnapshotTesting */, ); productName = CodeEditTests; productReference = B658FB3D27DA9E1000EA4DBD /* CodeEditTests.xctest */; @@ -428,7 +455,6 @@ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */, 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */, 58F2EB1C292FB954004A9BDE /* XCRemoteSwiftPackageReference "Sparkle" */, - 583E529A29361BAB001AB554 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */, 6C6BD6F229CD142C00235D17 /* XCRemoteSwiftPackageReference "collectionconcurrencykit" */, 6C66C31129D05CC800DE9ED2 /* XCRemoteSwiftPackageReference "GRDB.swift" */, @@ -438,7 +464,6 @@ 303E88452C276FD100EEA8D9 /* XCRemoteSwiftPackageReference "LanguageClient" */, 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, @@ -545,7 +570,6 @@ buildActionMask = 2147483647; files = ( 6CAAF69429BCD78600A1F48A /* (null) in Sources */, - 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */, 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */, 6CAAF69229BCC71C00A1F48A /* (null) in Sources */, 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */, @@ -674,7 +698,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -875,7 +899,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1148,7 +1172,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1414,14 +1438,14 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = CodeEdit/CodeEdit.entitlements; CODE_SIGN_IDENTITY = "-"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 47; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1465,7 +1489,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1702,14 +1726,6 @@ minimumVersion = 0.13.2; }; }; - 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/weichsel/ZIPFoundation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.9.19; - }; - }; 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ChimeHQ/LanguageServerProtocol"; @@ -1726,22 +1742,6 @@ minimumVersion = 0.8.0; }; }; - 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/weichsel/ZIPFoundation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.9.19; - }; - }; - 583E529A29361BAB001AB554 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 1.14.2; - }; - }; 58F2EB1C292FB954004A9BDE /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; @@ -1854,11 +1854,6 @@ package = 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */; productName = CodeEditSymbols; }; - 30818CB42D4E563900967860 /* ZIPFoundation */ = { - isa = XCSwiftPackageProductDependency; - package = 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; - productName = ZIPFoundation; - }; 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */ = { isa = XCSwiftPackageProductDependency; package = 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */; @@ -1869,16 +1864,59 @@ package = 30CB64922C16CA9100CC8A9E /* XCRemoteSwiftPackageReference "LanguageClient" */; productName = LanguageClient; }; - 583E529B29361BAB001AB554 /* SnapshotTesting */ = { + 5800E2F72FF843390085ECF1 /* CodeEditUI */ = { isa = XCSwiftPackageProductDependency; - package = 583E529A29361BAB001AB554 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; - productName = SnapshotTesting; + productName = CodeEditUI; + }; + 588950C42FFA5C05004BE116 /* CESearch */ = { + isa = XCSwiftPackageProductDependency; + productName = CESearch; + }; + 588957122FFA679E004BE116 /* ShellClient */ = { + isa = XCSwiftPackageProductDependency; + productName = ShellClient; + }; + 5889571430CE12AB004BE116 /* CEWorkspaceFileManager */ = { + isa = XCSwiftPackageProductDependency; + productName = CEWorkspaceFileManager; + }; + 5889639D2FFA9A87004BE116 /* CENotifications */ = { + isa = XCSwiftPackageProductDependency; + productName = CENotifications; + }; + 58CE15A000000001004BE200 /* CELSP */ = { + isa = XCSwiftPackageProductDependency; + productName = CELSP; + }; + 58CE50C100000002004BE301 /* CESourceControl */ = { + isa = XCSwiftPackageProductDependency; + productName = CESourceControl; + }; + 58CE7E4100000003004BE401 /* CETerminal */ = { + isa = XCSwiftPackageProductDependency; + productName = CETerminal; + }; + 58CFC49A2F8BE799009F4AA7 /* CodeEditCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditCore; + }; + 58ED10012FFB0001004BE116 /* CEEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CEEditor; }; 58F2EB1D292FB954004A9BDE /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = 58F2EB1C292FB954004A9BDE /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 5AD0C0DE2D00000000000002 /* CodeEditDocument */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditDocument; + }; + 5AD0C0DE2D00000000000012 /* CodeEditSettings */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSettings; + }; 5E4485602DF600D9008BBE69 /* AboutWindow */ = { isa = XCSwiftPackageProductDependency; package = 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */; diff --git a/CodeEdit.xcworkspace/contents.xcworkspacedata b/CodeEdit.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..60da1a46cf --- /dev/null +++ b/CodeEdit.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 92% rename from CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to CodeEdit.xcworkspace/xcshareddata/swiftpm/Package.resolved index b15a52165e..83e1abb0f1 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "01191ca9685501db65981a6fd21ab2d11c32196633d4cb776b5bb25908ed212f", + "originHash" : "56533eb948f3299004db23c63ba5d288ce60da898dee73acd05b73a6d3707a06", "pins" : [ { "identity" : "aboutwindow", @@ -208,24 +208,6 @@ "version" : "0.2.0" } }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", - "state" : { - "revision" : "bb0ea08db8e73324fe6c3727f755ca41a23ff2f4", - "version" : "1.14.2" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", - "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" - } - }, { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", @@ -303,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/weichsel/ZIPFoundation", "state" : { - "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", - "version" : "0.9.20" + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" } } ], diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/App/AppDelegate.swift similarity index 62% rename from CodeEdit/AppDelegate.swift rename to CodeEdit/App/AppDelegate.swift index 124e7fb4b5..60e9d50bd2 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/App/AppDelegate.swift @@ -5,7 +5,12 @@ // Created by Pavel Kasila on 12.03.22. // +import CELSP +import Combine +import CodeEditSettings +import CodeEditDocument import SwiftUI +import CodeEditCore import CodeEditSymbols import CodeEditSourceEditor import OSLog @@ -13,18 +18,37 @@ import OSLog @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "AppDelegate") - private let updater = SoftwareUpdater() @Environment(\.openWindow) var openWindow - @LazyService var lspService: LSPService + let dependencies = AppDependencies() + + var lspService: LSPService { dependencies.lspService } + var windowManager: any WorkspaceWindowManaging { dependencies.workspaceWindowManager } + var eventBus: EventBus { dependencies.eventBus } + + private lazy var shutdownCoordinator = ApplicationShutdownCoordinator( + windowManager: dependencies.workspaceWindowManager, + eventBus: dependencies.eventBus + ) + + private var cancellables = Set() func applicationDidFinishLaunching(_ notification: Notification) { + CodeFileDocument.isAutoSaveOnProvider = { [settings = dependencies.settingsAccessor] in + settings.value(GeneralSettings.self).isAutoSaveOn + } enableWindowSizeSaveOnQuit() - Settings.shared.preferences.general.appAppearance.applyAppearance() + dependencies.settingsAccessor.value(GeneralSettings.self).appAppearance.applyAppearance() checkForFilesToOpen() + // Subscribe to the welcome window event published by WorkspaceWindowManager + eventBus.subscribe(WelcomeWindowRequestedEvent.self) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.openWindow(sceneID: .welcome) } + .store(in: &cancellables) + NSApp.closeWindow(.welcome, .about) DispatchQueue.main.async { @@ -42,12 +66,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let path = CommandLine.arguments[index+1] let url = URL(fileURLWithPath: path) - CodeEditDocumentController.shared.reopenDocument( - for: url, - withContentsOf: url, - display: true - ) { document, _, _ in - document?.windowControllers.first?.synchronizeWindowTitleWithDocumentName() + do { + try self.windowManager.openWorkspace(at: url) + } catch { + self.logger.error("Failed to open workspace at \(path): \(error.localizedDescription)") } needToHandleOpen = false @@ -55,13 +77,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } if needToHandleOpen { - self.handleOpen() + // Reopen the workspaces that were open at last quit (saved by + // ApplicationShutdownCoordinator). Workspace windows are plain NSWindows, + // so NSQuitAlwaysKeepsWindows cannot restore them itself. + var restoredWorkspace = false + if let projects = UserDefaults.standard.array( + forKey: AppDelegate.recoverWorkspacesKey + ) as? [String] { + for path in projects { + do { + try self.windowManager.openWorkspace(at: URL(fileURLWithPath: path)) + restoredWorkspace = true + } catch { + self.logger.error( + "Failed to restore workspace at \(path): \(error.localizedDescription)" + ) + } + } + } + + if !restoredWorkspace { + self.handleOpen() + } } } } func applicationWillTerminate(_ aNotification: Notification) { - + cancellables.removeAll() } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -86,16 +129,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func handleOpen() { - let behavior = Settings.shared.preferences.general.reopenBehavior + let behavior = dependencies.settingsAccessor.value(GeneralSettings.self).reopenBehavior switch behavior { case .welcome: if !tryFocusWindow(id: .welcome) { openWindow(sceneID: .welcome) } case .openPanel: - CodeEditDocumentController.shared.openDocument(self) + windowManager.openDocumentFromPanel() case .newDocument: - CodeEditDocumentController.shared.newDocument(self) + windowManager.newDocumentFromPanel() } } @@ -107,18 +150,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let line = file.count > 1 ? Int(file[1]) ?? 0 : 0 let column = file.count > 2 ? Int(file[2]) ?? 1 : 1 - CodeEditDocumentController.shared - .openDocument(withContentsOf: filePath, display: true) { document, _, error in - if let error { - NSAlert(error: error).runModal() - return - } - if line > 0, let document = document as? CodeFileDocument { - document.openOptions = CodeFileDocument.OpenOptions( - cursorPositions: [CursorPosition(line: line, column: column > 0 ? column : 1)] - ) - } + do { + if filePath.isFolder { + try windowManager.openWorkspace(at: filePath) + } else if !windowManager.openFileInWorkspace(url: filePath) { + // Standalone file — open via NSDocumentController (for CodeFileDocument) + NSDocumentController.shared + .openDocument(withContentsOf: filePath, display: true) { document, _, error in + if let error { + NSAlert(error: error).runModal() + return + } + if line > 0, let document = document as? CodeFileDocument { + document.openOptions = CodeFileDocument.OpenOptions( + cursorPositions: [CursorPosition(line: line, column: column > 0 ? column : 1)] + ) + } + } } + } catch { + NSAlert(error: error).runModal() + } } } @@ -127,35 +179,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { /// Defers the application terminate message until we've finished cleanup. /// /// All paths _must_ call `NSApplication.shared.reply(toApplicationShouldTerminate: true)` as soon as possible. - /// - /// The two things needing deferring are: - /// - Language server cancellation - /// - Outstanding document changes. - /// - /// Things that don't need deferring (happen immediately): - /// - Task termination. - /// These are called immediately if no documents need closing, and are called by - /// ``documentController(_:didCloseAll:contextInfo:)`` if there are documents we need to defer for. - /// - /// See ``terminateLanguageServers()`` and ``documentController(_:didCloseAll:contextInfo:)`` for deferring tasks. func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - let projects: [String] = CodeEditDocumentController.shared.documents - .compactMap { ($0 as? WorkspaceDocument)?.fileURL?.path } - - UserDefaults.standard.set(projects, forKey: AppDelegate.recoverWorkspacesKey) - - let areAllDocumentsClean = CodeEditDocumentController.shared.documents.allSatisfy { !$0.isDocumentEdited } - guard areAllDocumentsClean else { - CodeEditDocumentController.shared.closeAllDocuments( - withDelegate: self, - didCloseAllSelector: #selector(documentController(_:didCloseAll:contextInfo:)), - contextInfo: nil - ) - // `documentController(_:didCloseAll:contextInfo:)` will call `terminateLanguageServers()` - return .terminateLater + guard shutdownCoordinator.execute() else { + return .terminateCancel } - terminateTasks() terminateLanguageServers() return .terminateLater } @@ -177,11 +205,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @IBAction func openFeedback(_ sender: Any) { if tryFocusWindow(of: FeedbackView.self) { return } - FeedbackView().showWindow() + FeedbackView().showWindow(settingsStore: dependencies.settingsStore) } @IBAction private func checkForUpdates(_ sender: Any) { - updater.checkForUpdates() + dependencies.softwareUpdater.checkForUpdates() } /// Tries to focus a window with specified view content type. @@ -223,12 +251,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { for filePath in files { let fileURL = URL(fileURLWithPath: String(filePath)) - CodeEditDocumentController.shared.reopenDocument( - for: fileURL, - withContentsOf: fileURL, - display: true - ) { document, _, _ in - document?.windowControllers.first?.synchronizeWindowTitleWithDocumentName() + do { + try windowManager.openWorkspace(at: fileURL) + } catch { + logger.error("Failed to open \(filePath): \(error.localizedDescription)") } } @@ -246,16 +272,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") } - // MARK: NSDocumentController delegate - - @objc - func documentController(_ docController: NSDocumentController, didCloseAll: Bool, contextInfo: Any) { - if didCloseAll { - terminateTasks() - terminateLanguageServers() - } - } - /// Terminates running language servers. Used during app termination to ensure resources are freed. private func terminateLanguageServers() { Task { @MainActor in @@ -267,7 +283,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { ) if !lspService.languageClients.isEmpty { - TaskNotificationHandler.postTask(action: .create, model: task) + eventBus.publish(TaskNotificationEvent(.create(task))) } try? await withTimeout( @@ -281,34 +297,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } ) - TaskNotificationHandler.postTask(action: .delete, model: task) + eventBus.publish(TaskNotificationEvent(.delete(id: task.id))) NSApplication.shared.reply(toApplicationShouldTerminate: true) } } - /// Terminates all running tasks. Used during app termination to ensure resources are freed. - private func terminateTasks() { - let task = TaskNotificationModel( - id: "appdelegate.terminate_tasks", - title: "Terminating Tasks", - message: "Interrupting all running tasks before quitting...", - isLoading: true - ) - - let taskManagers = CodeEditDocumentController.shared.documents - .compactMap({ $0 as? WorkspaceDocument }) - .compactMap({ $0.taskManager }) - - if taskManagers.reduce(0, { $0 + $1.activeTasks.count }) > 0 { - TaskNotificationHandler.postTask(action: .create, model: task) - } - - taskManagers.forEach { manager in - manager.stopAllTasks() - } - - TaskNotificationHandler.postTask(action: .delete, model: task) - } } extension AppDelegate { diff --git a/CodeEdit/App/AppDependencies.swift b/CodeEdit/App/AppDependencies.swift new file mode 100644 index 0000000000..e97b2ea3fd --- /dev/null +++ b/CodeEdit/App/AppDependencies.swift @@ -0,0 +1,134 @@ +// +// AppDependencies.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 10/07/2026. +// + +import CELSP +import CodeEditCore +import CodeEditDocument +import CodeEditSettings +import CENotifications +import ShellClient + +/// The app-scope composition root. Owns every process-lifetime service and is the only +/// place where interfaces are bound to implementations. +/// +/// Ownership: `AppDelegate` creates the single instance; `CodeEditApp` reads it through the +/// delegate adaptor. Only composition roots (`AppDelegate`, `CodeEditApp`, +/// `WorkspaceWindowManager` and its use cases, `WorkspaceFactory`, the window controllers) +/// may hold this whole object — everything else declares the specific dependencies it needs, +/// via initializer parameters (objects) or environment keys (views, see `appServices(_:)`). +/// +/// Properties are `lazy` to preserve on-first-use construction timing (`RegistryManager` +/// performs I/O when created) and to let `workspaceWindowManager`-adjacent adapters +/// reference sibling properties without initialization-order cycles. +@MainActor +final class AppDependencies { + private(set) lazy var eventBus = EventBus() + + private(set) lazy var shellClient: ShellClientProtocol = ShellClient() + + /// The app's settings: the one store, owned here rather than reached through a singleton. + /// + /// Concrete (not `SettingsAccessing`) because the injectors also need its `revision` publisher + /// to observe. Consumers that only read or write settings take ``settingsAccessor`` instead. + private(set) lazy var settingsStore = PersistentSettingsStore() + + /// Feature-side settings access, read and write. The same object as ``settingsStore``, narrowed + /// to the seam's protocol so nothing outside the composition root names the concrete type. + private(set) lazy var settingsAccessor: SettingsAccessing = settingsStore + + private(set) lazy var commandManager: CommandManaging = CommandManager() + + private(set) lazy var keybindingManager: KeybindingManaging = KeybindingManager() + + private(set) lazy var notificationManager: NotificationManaging = NotificationManager(eventBus: eventBus) + + /// The single Sparkle updater controller. One instance app-wide: the "Check for + /// Updates" menu action and the Settings auto-update toggles must observe the same + /// `SPUUpdater`, or their state drifts apart. + private(set) lazy var softwareUpdater = SoftwareUpdater() + + private(set) lazy var lspService: LSPService = { + let service = LSPService(settingsReader: settingsAccessor) + // Property-injected (not init-injected): the window manager's construction consumes + // `lspService`, so init injection in both directions would recurse. Resolved at call + // time, long after both objects exist. + service.workspaceFinder = { [weak self] url in + self?.workspaceWindowManager.workspace(containing: url)?.fileURL + } + return service + }() + + private(set) lazy var errorNotifier: ErrorNotifying = AppErrorNotifier(notificationManager: notificationManager) + + private(set) lazy var registryManager = RegistryManager( + eventBus: eventBus, + errorNotifier: errorNotifier, + shellClient: shellClient, + settingsAccessor: settingsAccessor, + // Handed its install location rather than reading it from a singleton. A later slice gives + // `RegistryManager` a proper home for this path; until then the composition root supplies it. + installPath: settingsStore.baseURL.appending(path: "Language Servers") + ) + + private(set) lazy var workspaceWindowManager = WorkspaceWindowManager(dependencies: self) + + // MARK: - Command-interface adapters (stateless routers over the window manager) + + private(set) lazy var workspaceFileOpener: WorkspaceFileOpener = + AppWorkspaceFileOpener(windowManager: workspaceWindowManager) + + private(set) lazy var workspaceNavigator: WorkspaceNavigator = + AppWorkspaceNavigator(windowManager: workspaceWindowManager) + + private(set) lazy var fileRelocator: FileRelocator = + AppFileRelocator(windowManager: workspaceWindowManager) + + private(set) lazy var languageServicesProvider: LanguageServicesProvider = + AppLanguageServicesProvider(lspService: lspService) + + // MARK: - Wiring that must not wait for a delegate callback + + init() { + installSettingsStore() + } + + /// Hands the settings store to the three pre-existing singletons that cannot take it through + /// their own `init`, before anything can read them. + /// + /// Deliberately here and not in an `AppDelegate` callback. It first lived in + /// `applicationDidFinishLaunching`, which `application(_:open urls:)` can beat: launching by + /// double-clicking a folder in Finder, or via a `codeedit://` URL at cold start, opens a + /// workspace window before that callback runs. `ThemeModel` would then still hold + /// `DefaultSettingsReader` — a debug trap, and in release a crash, because + /// `CodeEditWindowController` force-unwraps `ThemeModel.shared.themes.first!` on an array that + /// never loaded. The singleton these replaced was immune to that ordering; this is. + /// + /// `AppDelegate` holds `dependencies` as a non-lazy stored property, so this runs while the + /// delegate itself is being initialized — before *any* delegate callback, not merely before the + /// one that happened to be a problem. Moving it to `applicationWillFinishLaunching` would fix + /// today's ordering and leave the next earlier callback free to reintroduce it. + /// + /// The cost is that `settingsStore` and `ThemeModel` are built eagerly rather than on first use, + /// which front-loads reading `settings.json` and the theme files. Both are read by the first + /// window anyway, so this moves the work earlier rather than adding it. + private func installSettingsStore() { + ThemeModel.shared.configure(settings: settingsAccessor) + FeedbackModel.shared.settingsAccessor = settingsAccessor + SearchSettingsModel.shared.configure(settings: settingsAccessor) + } + + // MARK: - Command-interface adapters, continued + + private(set) lazy var codeFileDocumentDelegate: CodeFileDocumentDelegate = + AppCodeFileDocumentDelegate( + lspService: lspService, + windowManager: workspaceWindowManager, + languageServices: languageServicesProvider, + settingsStore: settingsStore, + activeTheme: ThemeModel.shared.activeTheme + ) +} diff --git a/CodeEdit/App/CodeEditApp.swift b/CodeEdit/App/CodeEditApp.swift new file mode 100644 index 0000000000..bd9c01fd52 --- /dev/null +++ b/CodeEdit/App/CodeEditApp.swift @@ -0,0 +1,97 @@ +// +// CodeEditApp.swift +// CodeEdit +// +// Created by Wouter Hennen on 11/03/2023. +// + +import SwiftUI +import CodeEditSettings +import CodeEditDocument +import CodeEditCore +import WelcomeWindow +import AboutWindow + +@main +struct CodeEditApp: App { + @NSApplicationDelegateAdaptor var appdelegate: AppDelegate + + init() { + NSMenuItem.swizzle() + NSSplitViewItem.swizzle() + let dependencies = appdelegate.dependencies + CodeFileDocument.delegateProvider = { + dependencies.codeFileDocumentDelegate + } + TextEditingSettings.registerCommands( + in: dependencies.commandManager, + settings: dependencies.settingsAccessor + ) + KeybindingsSettings.reconcileDefaults( + keybindingManager: dependencies.keybindingManager, + settings: dependencies.settingsAccessor + ) + } + + var body: some Scene { + SettingsSceneInjector(store: appdelegate.dependencies.settingsStore) { + WelcomeWindow( + subtitleView: { WelcomeSubtitleView() }, + actions: { dismissWindow in + NewFileButton( + windowManager: appdelegate.dependencies.workspaceWindowManager, + dismissWindow: dismissWindow + ) + GitCloneButton( + windowManager: appdelegate.dependencies.workspaceWindowManager, + shellClient: appdelegate.dependencies.shellClient, + dismissWindow: dismissWindow + ) + OpenFileOrFolderButton( + windowManager: appdelegate.dependencies.workspaceWindowManager, + dismissWindow: dismissWindow + ) + }, + onDrop: { url, dismissWindow in + let windowManager = appdelegate.dependencies.workspaceWindowManager + Task { + do { + try windowManager.openWorkspace(at: url) + dismissWindow() + } catch { + print("Failed to open workspace: \(error)") + } + } + }, + openHandler: { urls, dismissWindow in + let windowManager = appdelegate.dependencies.workspaceWindowManager + for url in urls { + windowManager.openDocument(at: url, onCompletion: {}) + } + dismissWindow() + } + ) + + ExtensionManagerWindow() + + AboutWindow( + subtitleView: { AboutSubtitleView() }, + actions: { + AboutButton(title: "Contributors", destination: { + ContributorsView() + }) + AboutButton(title: "Acknowledgements", destination: { + AcknowledgementsView() + }) + }, + footer: { AboutFooterView() } + ) + + SettingsWindow(updater: appdelegate.dependencies.softwareUpdater) + .commands { + CodeEditCommands(dependencies: appdelegate.dependencies) + } + } + .appServices(appdelegate.dependencies) + } +} diff --git a/CodeEdit/App/Commands/CommandManager.swift b/CodeEdit/App/Commands/CommandManager.swift new file mode 100644 index 0000000000..1a81402ab8 --- /dev/null +++ b/CodeEdit/App/Commands/CommandManager.swift @@ -0,0 +1,31 @@ +// +// CommandManager.swift +// +// Created by Alex on 23.05.2022. +// + +import Foundation +import CodeEditCore + +/// Registry backing the command palette. Owned by `AppDependencies`; objects receive it +/// through their initializer, views through the `\.commandManager` environment key. +final class CommandManager: CommandManaging { + private var commandsList: [String: Command] + + init() { + commandsList = [:] + } + + func addCommand(name: String, title: String, id: String, command: @escaping () -> Void) { + let command = Command.init(id: name, title: title, closureWrapper: command) + commandsList[id] = command + } + + var commands: [Command] { + return commandsList.map { $0.value } + } + + func executeCommand(_ id: String) { + commandsList[id]?.closureWrapper() + } +} diff --git a/CodeEdit/App/Commands/CommandManaging.swift b/CodeEdit/App/Commands/CommandManaging.swift new file mode 100644 index 0000000000..e8539905fc --- /dev/null +++ b/CodeEdit/App/Commands/CommandManaging.swift @@ -0,0 +1,16 @@ +// +// CommandManaging.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 11/04/26. +// + +import Foundation +import CodeEditCore + +/// Protocol for managing application commands (command palette). +protocol CommandManaging: AnyObject { + var commands: [Command] { get } + func addCommand(name: String, title: String, id: String, command: @escaping () -> Void) + func executeCommand(_ id: String) +} diff --git a/CodeEdit/App/Commands/KeybindingManager.swift b/CodeEdit/App/Commands/KeybindingManager.swift new file mode 100644 index 0000000000..5121121ba0 --- /dev/null +++ b/CodeEdit/App/Commands/KeybindingManager.swift @@ -0,0 +1,58 @@ +// +// KeybindingManager.swift +// +// Created by Alex on 09.05.2022. +// + +import Foundation +import SwiftUI +import CodeEditSettings + +final class KeybindingManager: KeybindingManaging { + /// Array which contains all available keyboard shortcuts + var keyboardShortcuts = [String: KeyboardShortcutWrapper]() + + init() { + loadKeybindings() + } + + // We need this fallback shortcut because optional shortcuts available only from 12.3, while we have target of 12.0x + var fallbackShortcut = KeyboardShortcutWrapper( + name: "?", + description: "Test", + context: "Fallback", + keybinding: "?", + modifier: "shift", + id: "fallback" + ) + + /// Adds new shortcut + func addNewShortcut(shortcut: KeyboardShortcutWrapper, name: String) { + keyboardShortcuts[name] = shortcut + } + + private func loadKeybindings() { + + let bindingsURL = Bundle.main.url(forResource: "default_keybindings.json", withExtension: nil) + if let json = try? Data(contentsOf: bindingsURL!) { + do { + let prefs = try JSONDecoder().decode([KeyboardShortcutWrapper].self, from: json) + for pref in prefs { + addNewShortcut(shortcut: pref, name: pref.id) + } + } catch { + print("error:\(error)") + } + } + return + } + + /// Get shortcut by name + /// - Parameter name: shortcut name + /// - Returns: KeyboardShortcutWrapper + func named(with name: String) -> KeyboardShortcutWrapper { + let foundElement = keyboardShortcuts[name] + return foundElement != nil ? foundElement! : fallbackShortcut + } + +} diff --git a/CodeEdit/App/Commands/KeybindingManaging.swift b/CodeEdit/App/Commands/KeybindingManaging.swift new file mode 100644 index 0000000000..15565f16b2 --- /dev/null +++ b/CodeEdit/App/Commands/KeybindingManaging.swift @@ -0,0 +1,16 @@ +// +// KeybindingManaging.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 11/04/26. +// + +import Foundation +import CodeEditSettings + +/// Protocol for managing keyboard shortcuts. +protocol KeybindingManaging: AnyObject { + var keyboardShortcuts: [String: KeyboardShortcutWrapper] { get } + func addNewShortcut(shortcut: KeyboardShortcutWrapper, name: String) + func named(with name: String) -> KeyboardShortcutWrapper +} diff --git a/CodeEdit/Features/Keybindings/ModifierKeysObserver.swift b/CodeEdit/App/Commands/ModifierKeysObserver.swift similarity index 89% rename from CodeEdit/Features/Keybindings/ModifierKeysObserver.swift rename to CodeEdit/App/Commands/ModifierKeysObserver.swift index c0a1329c14..fc0d090a64 100644 --- a/CodeEdit/Features/Keybindings/ModifierKeysObserver.swift +++ b/CodeEdit/App/Commands/ModifierKeysObserver.swift @@ -8,17 +8,6 @@ import SwiftUI import Combine -struct EventModifierEnvironmentKey: EnvironmentKey { - static var defaultValue: NSEvent.ModifierFlags = [] -} - -extension EnvironmentValues { - var modifierKeys: EventModifierEnvironmentKey.Value { - get { self[EventModifierEnvironmentKey.self] } - set { self[EventModifierEnvironmentKey.self] = newValue } - } -} - extension NSEvent { static func publisher(scope: Publisher.Scope, matching: EventTypeMask) -> Publisher { return Publisher(scope: scope, matching: matching) diff --git a/CodeEdit/Features/Keybindings/default_keybindings.json b/CodeEdit/App/Commands/default_keybindings.json similarity index 100% rename from CodeEdit/Features/Keybindings/default_keybindings.json rename to CodeEdit/App/Commands/default_keybindings.json diff --git a/CodeEdit/App/Environment+AppCommands.swift b/CodeEdit/App/Environment+AppCommands.swift new file mode 100644 index 0000000000..54f292ab0c --- /dev/null +++ b/CodeEdit/App/Environment+AppCommands.swift @@ -0,0 +1,128 @@ +// +// Environment+AppCommands.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 10/07/2026. +// + +import CELSP +import SwiftUI +import CodeEditCore +import CodeEditSettings +import CEEditor +import CENotifications +import CESearch +import ShellClient + +private struct FileRelocatorKey: EnvironmentKey { + static let defaultValue: FileRelocator = NoOpFileRelocator() +} + +extension EnvironmentValues { + /// The command used to move/rename a file within its owning workspace. + /// No-op by default (previews, tests); injected at the workspace window's root. + var fileRelocator: FileRelocator { + get { self[FileRelocatorKey.self] } + set { self[FileRelocatorKey.self] = newValue } + } +} + +private struct CommandManagerKey: EnvironmentKey { + static let defaultValue: CommandManaging? = nil +} + +private struct ShellClientKey: EnvironmentKey { + static let defaultValue: ShellClientProtocol? = nil +} + +private struct LanguageServerListStateKey: EnvironmentKey { + static let defaultValue: LanguageServerListState? = nil +} + +private struct RegistryManagerKey: EnvironmentKey { + static let defaultValue: (any RegistryManaging)? = nil +} + +private struct WorkspaceWindowManagerKey: EnvironmentKey { + static let defaultValue: (any WorkspaceWindowManaging)? = nil +} + +private struct EventBusKey: EnvironmentKey { + /// A fresh, isolated bus: previews and tests publish into the void, which is legitimate. + static let defaultValue: EventBus = EventBus() +} + +extension EnvironmentValues { + /// The command palette registry. Optional: command UI no-ops in previews. Injected by the app shell. + var commandManager: CommandManaging? { + get { self[CommandManagerKey.self] } + set { self[CommandManagerKey.self] = newValue } + } + + /// The shell client for git and other subprocess work. Optional: git UI without a shell + /// only occurs in previews. Injected by the app shell. + var shellClient: ShellClientProtocol? { + get { self[ShellClientKey.self] } + set { self[ShellClientKey.self] = newValue } + } + + /// The observable list of running language servers. Optional: empty in previews. + var languageServerListState: LanguageServerListState? { + get { self[LanguageServerListStateKey.self] } + set { self[LanguageServerListStateKey.self] = newValue } + } + + /// The language-server registry. Optional: registry UI is empty in previews. Injected by the app shell. + var registryManager: (any RegistryManaging)? { + get { self[RegistryManagerKey.self] } + set { self[RegistryManagerKey.self] = newValue } + } + + /// The workspace window manager, for flows that open arbitrary files or workspaces + /// (e.g. Settings pages opening ~/.gitconfig). Optional: nil in previews. + var workspaceWindowManager: (any WorkspaceWindowManaging)? { + get { self[WorkspaceWindowManagerKey.self] } + set { self[WorkspaceWindowManagerKey.self] = newValue } + } + + /// The app-wide event bus. Defaults to an isolated instance so previews publish harmlessly. + var eventBus: EventBus { + get { self[EventBusKey.self] } + set { self[EventBusKey.self] = newValue } + } +} + +extension View { + /// Injects the app-scope services into this view subtree. Applied at every SwiftUI root: + /// the app's scenes and each workspace window's split-view content. + func appServices(_ dependencies: AppDependencies) -> some View { + environment(\.commandManager, dependencies.commandManager) + .environment(\.shellClient, dependencies.shellClient) + .environment(\.languageServerListState, dependencies.lspService.serverListState) + .environment(\.registryManager, dependencies.registryManager) + .environment(\.eventBus, dependencies.eventBus) + .environment(\.workspaceWindowManager, dependencies.workspaceWindowManager) + .environment(\.notificationManager, dependencies.notificationManager) + .environment(\.fileRelocator, dependencies.fileRelocator) + .environment(\.workspaceFileOpener, dependencies.workspaceFileOpener) + .environment(\.workspaceNavigator, dependencies.workspaceNavigator) + .environment(\.languageServices, dependencies.languageServicesProvider) + } +} + +extension Scene { + /// Scene-level counterpart of `View.appServices(_:)` for the app's scene group. + func appServices(_ dependencies: AppDependencies) -> some Scene { + environment(\.commandManager, dependencies.commandManager) + .environment(\.shellClient, dependencies.shellClient) + .environment(\.languageServerListState, dependencies.lspService.serverListState) + .environment(\.registryManager, dependencies.registryManager) + .environment(\.eventBus, dependencies.eventBus) + .environment(\.workspaceWindowManager, dependencies.workspaceWindowManager) + .environment(\.notificationManager, dependencies.notificationManager) + .environment(\.fileRelocator, dependencies.fileRelocator) + .environment(\.workspaceFileOpener, dependencies.workspaceFileOpener) + .environment(\.workspaceNavigator, dependencies.workspaceNavigator) + .environment(\.languageServices, dependencies.languageServicesProvider) + } +} diff --git a/CodeEdit/App/MenuBar/CodeEditCommands.swift b/CodeEdit/App/MenuBar/CodeEditCommands.swift new file mode 100644 index 0000000000..c1069c858d --- /dev/null +++ b/CodeEdit/App/MenuBar/CodeEditCommands.swift @@ -0,0 +1,46 @@ +// +// CodeEditCommands.swift +// CodeEdit +// +// Created by Wouter Hennen on 11/03/2023. +// + +import CodeEditSettings +import SwiftUI + +struct CodeEditCommands: Commands { + let dependencies: AppDependencies + + /// The settings store, taken from `dependencies` rather than from the environment. + /// + /// See ``ViewCommands/settingsStore`` for why: `Commands` content is not part of the view + /// hierarchy, so `@Environment` — and with it `@AppSettings` — cannot be relied on here. Without + /// a real store this menu would build with `DefaultSettingsReader`, trapping in debug and showing + /// the Source Control group unconditionally in release. + @ObservedObject private var settingsStore: PersistentSettingsStore + + init(dependencies: AppDependencies) { + self.dependencies = dependencies + self.settingsStore = dependencies.settingsStore + } + + private var sourceControlIsEnabled: Bool { + SettingsData(accessor: settingsStore).sourceControl.general.sourceControlIsEnabled + } + + var body: some Commands { + Group { // SwiftUI limits to 9 items in an initializer, so we have to group every 9 items. + MainCommands() + FileCommands(windowManager: dependencies.workspaceWindowManager) + ViewCommands(settingsStore: settingsStore) + FindCommands() + NavigateCommands() + TasksCommands() + if sourceControlIsEnabled { SourceControlCommands() } + EditorCommands() + ExtensionCommands() + WindowCommands() + } + HelpCommands() + } +} diff --git a/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift b/CodeEdit/App/MenuBar/CommandsFixes.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift rename to CodeEdit/App/MenuBar/CommandsFixes.swift diff --git a/CodeEdit/Features/WindowCommands/EditorCommands.swift b/CodeEdit/App/MenuBar/EditorCommands.swift similarity index 91% rename from CodeEdit/Features/WindowCommands/EditorCommands.swift rename to CodeEdit/App/MenuBar/EditorCommands.swift index e99c8dfa36..5b58a7036c 100644 --- a/CodeEdit/Features/WindowCommands/EditorCommands.swift +++ b/CodeEdit/App/MenuBar/EditorCommands.swift @@ -6,13 +6,14 @@ // import SwiftUI +import CEEditor import CodeEditKit struct EditorCommands: Commands { @UpdatingWindowController var windowController: CodeEditWindowController? private var editor: Editor? { - windowController?.workspace?.editorManager?.activeEditor + windowController?.workspace?.editorManager.activeEditor } var body: some Commands { diff --git a/CodeEdit/Features/WindowCommands/ExtensionCommands.swift b/CodeEdit/App/MenuBar/ExtensionCommands.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/ExtensionCommands.swift rename to CodeEdit/App/MenuBar/ExtensionCommands.swift diff --git a/CodeEdit/Features/WindowCommands/FileCommands.swift b/CodeEdit/App/MenuBar/FileCommands.swift similarity index 95% rename from CodeEdit/Features/WindowCommands/FileCommands.swift rename to CodeEdit/App/MenuBar/FileCommands.swift index 130ce0e5b1..33a36e2318 100644 --- a/CodeEdit/Features/WindowCommands/FileCommands.swift +++ b/CodeEdit/App/MenuBar/FileCommands.swift @@ -10,6 +10,8 @@ import SwiftUI struct FileCommands: Commands { static let recentProjectsMenu = RecentProjectsMenu() + let windowManager: any WorkspaceWindowManaging + @Environment(\.openWindow) private var openWindow @@ -21,12 +23,12 @@ struct FileCommands: Commands { CommandGroup(replacing: .newItem) { Group { Button("New") { - NSDocumentController.shared.newDocument(nil) + windowManager.newDocumentFromPanel() } .keyboardShortcut("n") Button("Open...") { - NSDocumentController.shared.openDocument(nil) + windowManager.openDocumentFromPanel() } .keyboardShortcut("o") diff --git a/CodeEdit/Features/WindowCommands/FindCommands.swift b/CodeEdit/App/MenuBar/FindCommands.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/FindCommands.swift rename to CodeEdit/App/MenuBar/FindCommands.swift diff --git a/CodeEdit/Features/WindowCommands/Utils/FirstResponderPropertyWrapper.swift b/CodeEdit/App/MenuBar/FirstResponderPropertyWrapper.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/Utils/FirstResponderPropertyWrapper.swift rename to CodeEdit/App/MenuBar/FirstResponderPropertyWrapper.swift diff --git a/CodeEdit/Utils/FocusedValues.swift b/CodeEdit/App/MenuBar/FocusedValues.swift similarity index 100% rename from CodeEdit/Utils/FocusedValues.swift rename to CodeEdit/App/MenuBar/FocusedValues.swift diff --git a/CodeEdit/Features/WindowCommands/HelpCommands.swift b/CodeEdit/App/MenuBar/HelpCommands.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/HelpCommands.swift rename to CodeEdit/App/MenuBar/HelpCommands.swift diff --git a/CodeEdit/Features/WindowCommands/Utils/KeyWindowControllerObserver.swift b/CodeEdit/App/MenuBar/KeyWindowControllerObserver.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/Utils/KeyWindowControllerObserver.swift rename to CodeEdit/App/MenuBar/KeyWindowControllerObserver.swift diff --git a/CodeEdit/Features/WindowCommands/MainCommands.swift b/CodeEdit/App/MenuBar/MainCommands.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/MainCommands.swift rename to CodeEdit/App/MenuBar/MainCommands.swift diff --git a/CodeEdit/Features/WindowCommands/NavigateCommands.swift b/CodeEdit/App/MenuBar/NavigateCommands.swift similarity index 96% rename from CodeEdit/Features/WindowCommands/NavigateCommands.swift rename to CodeEdit/App/MenuBar/NavigateCommands.swift index 45b9cddf38..20e73f97ba 100644 --- a/CodeEdit/Features/WindowCommands/NavigateCommands.swift +++ b/CodeEdit/App/MenuBar/NavigateCommands.swift @@ -6,12 +6,13 @@ // import SwiftUI +import CEEditor struct NavigateCommands: Commands { @UpdatingWindowController var windowController: CodeEditWindowController? private var editor: Editor? { - windowController?.workspace?.editorManager?.activeEditor + windowController?.workspace?.editorManager.activeEditor } var body: some Commands { diff --git a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift b/CodeEdit/App/MenuBar/RecentProjectsMenu.swift similarity index 91% rename from CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift rename to CodeEdit/App/MenuBar/RecentProjectsMenu.swift index 67ad05cc80..d9623779c0 100644 --- a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift +++ b/CodeEdit/App/MenuBar/RecentProjectsMenu.swift @@ -126,11 +126,12 @@ final class RecentProjectsMenu: NSObject, NSMenuDelegate { @objc private func recentProjectItemClicked(_ sender: NSMenuItem) { guard let projectURL = sender.representedObject as? URL else { return } - CodeEditDocumentController.shared.openDocument( - withContentsOf: projectURL, - display: true, - completionHandler: { _, _, _ in } - ) + // This menu is installed via a hidden AppKit API (see CommandsFixes.swift), outside any + // injectable construction flow, so it reaches the composition root through the app delegate. + guard let windowManager = (NSApp.delegate as? AppDelegate)?.dependencies.workspaceWindowManager else { + return + } + windowManager.openDocument(at: projectURL, onCompletion: {}) } @objc diff --git a/CodeEdit/Features/WindowCommands/SourceControlCommands.swift b/CodeEdit/App/MenuBar/SourceControlCommands.swift similarity index 78% rename from CodeEdit/Features/WindowCommands/SourceControlCommands.swift rename to CodeEdit/App/MenuBar/SourceControlCommands.swift index 6bf494f412..6905a0c983 100644 --- a/CodeEdit/Features/WindowCommands/SourceControlCommands.swift +++ b/CodeEdit/App/MenuBar/SourceControlCommands.swift @@ -5,6 +5,7 @@ // Created by Austin Condiff on 6/29/24. // +import CESourceControl import SwiftUI struct SourceControlCommands: Commands { @@ -16,6 +17,10 @@ struct SourceControlCommands: Commands { windowController?.workspace?.sourceControlManager } + var sourceControlViewModel: SourceControlViewModel? { + windowController?.workspace?.sourceControlViewModel + } + var body: some Commands { CommandMenu("Source Control") { Group { @@ -25,16 +30,16 @@ struct SourceControlCommands: Commands { .disabled(true) Button("Push...") { - sourceControlManager?.pushSheetIsPresented = true + sourceControlViewModel?.pushSheetIsPresented = true } Button("Pull...") { - sourceControlManager?.pullSheetIsPresented = true + sourceControlViewModel?.pullSheetIsPresented = true } .keyboardShortcut("x", modifiers: [.command, .option]) Button("Fetch Changes") { - sourceControlManager?.fetchSheetIsPresented = true + sourceControlViewModel?.fetchSheetIsPresented = true } Divider() @@ -42,7 +47,7 @@ struct SourceControlCommands: Commands { Button("Stage All Changes") { guard let sourceControlManager else { return } if sourceControlManager.changedFiles.isEmpty { - sourceControlManager.noChangesToStageAlertIsPresented = true + sourceControlViewModel?.noChangesToStageAlertIsPresented = true } else { Task { do { @@ -60,7 +65,7 @@ struct SourceControlCommands: Commands { Button("Unstage All Changes") { guard let sourceControlManager else { return } if sourceControlManager.changedFiles.isEmpty { - sourceControlManager.noChangesToUnstageAlertIsPresented = true + sourceControlViewModel?.noChangesToUnstageAlertIsPresented = true } else { Task { do { @@ -86,9 +91,9 @@ struct SourceControlCommands: Commands { Button("Stash Changes...") { if sourceControlManager?.changedFiles.isEmpty ?? false { - sourceControlManager?.noChangesToStashAlertIsPresented = true + sourceControlViewModel?.noChangesToStashAlertIsPresented = true } else { - sourceControlManager?.stashSheetIsPresented = true + sourceControlViewModel?.stashSheetIsPresented = true } } @@ -96,16 +101,16 @@ struct SourceControlCommands: Commands { Button("Discard All Changes...") { if sourceControlManager?.changedFiles.isEmpty ?? false { - sourceControlManager?.noChangesToDiscardAlertIsPresented = true + sourceControlViewModel?.noChangesToDiscardAlertIsPresented = true } else { - sourceControlManager?.discardAllAlertIsPresented = true + sourceControlViewModel?.discardAllAlertIsPresented = true } } Divider() Button("Add Exisiting Remote...") { - sourceControlManager?.addExistingRemoteSheetIsPresented = true + sourceControlViewModel?.addExistingRemoteSheetIsPresented = true } } .disabled(windowController?.workspace == nil) diff --git a/CodeEdit/Features/WindowCommands/TasksCommands.swift b/CodeEdit/App/MenuBar/TasksCommands.swift similarity index 94% rename from CodeEdit/Features/WindowCommands/TasksCommands.swift rename to CodeEdit/App/MenuBar/TasksCommands.swift index ef067b30c8..b7d44904de 100644 --- a/CodeEdit/Features/WindowCommands/TasksCommands.swift +++ b/CodeEdit/App/MenuBar/TasksCommands.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import CETerminal struct TasksCommands: Commands { @UpdatingWindowController var windowController: CodeEditWindowController? @@ -93,14 +94,14 @@ struct TasksCommands: Commands { } private func showOutput() { - guard let utilityAreaModel = windowController?.workspace?.utilityAreaModel else { + guard let utilityAreaModel = windowController?.utilityAreaModel else { return } if utilityAreaModel.isCollapsed { // Open the utility area utilityAreaModel.isCollapsed.toggle() } - utilityAreaModel.selectedTab = .debugConsole // Switch to the correct tab + utilityAreaModel.selectedTabID = PanelTabID.debugConsole // Switch to the correct tab taskManager?.taskShowingOutput = taskManager?.selectedTaskID // Switch to the selected task } diff --git a/CodeEdit/Features/WindowCommands/ViewCommands.swift b/CodeEdit/App/MenuBar/ViewCommands.swift similarity index 54% rename from CodeEdit/Features/WindowCommands/ViewCommands.swift rename to CodeEdit/App/MenuBar/ViewCommands.swift index c72ccb0710..0846ba3281 100644 --- a/CodeEdit/Features/WindowCommands/ViewCommands.swift +++ b/CodeEdit/App/MenuBar/ViewCommands.swift @@ -5,18 +5,25 @@ // Created by Wouter Hennen on 13/03/2023. // +import CodeEditSettings import SwiftUI -import Combine struct ViewCommands: Commands { - @AppSettings(\.textEditing.font.size) - var editorFontSize - @AppSettings(\.terminal.font.size) - var terminalFontSize - @AppSettings(\.general.showEditorJumpBar) - var showEditorJumpBar - @AppSettings(\.general.dimEditorsWithoutFocus) - var dimEditorsWithoutFocus + + /// The settings store, handed in by `CodeEditCommands` rather than read from the environment. + /// + /// `Commands` content is **not** part of the view hierarchy: `.commands { }` attaches to a + /// `Scene` beside its content, so whether the `.environment` values `SettingsSceneInjector` + /// applies to that content also reach here is undocumented SwiftUI behaviour. Menu items that + /// read *and write* user settings must not rest on it — if it ever stopped holding, Font Size + /// and the Jump Bar toggle would become silent no-ops and `DefaultSettingsReader` would trap + /// while the menu bar is built. + /// + /// Observed, not merely held: the menu reflects settings state (the Jump Bar item's title, the + /// Dim-editors check mark), so it has to re-evaluate when they change. `ObservableObject` + /// observation inside a `Commands` conformer is already load-bearing here — it is how + /// ``UpdatingWindowController`` keeps the Show/Hide titles below current. + @ObservedObject private var settingsStore: PersistentSettingsStore @FocusedBinding(\.navigationSplitViewVisibility) var navigationSplitViewVisibility @@ -26,6 +33,48 @@ struct ViewCommands: Commands { @UpdatingWindowController var windowController: CodeEditWindowController? + init(settingsStore: PersistentSettingsStore) { + self.settingsStore = settingsStore + } + + /// A fresh façade over the store. Stateless, so building one per access is free. + private var settings: SettingsData { + SettingsData(accessor: settingsStore) + } + + /// The same read-modify-write `AppSettings`' `projectedValue` performs, without the environment. + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + settings[keyPath: keyPath] + } set: { newValue in + var settings = SettingsData(accessor: settingsStore) + settings[keyPath: keyPath] = newValue + } + } + + /// Nudges the editor and terminal font sizes together, each clamped independently so one already + /// at the limit does not stop the other from moving. Both bounds match the Text Editing and + /// Terminal settings pages. + private func adjustFontSizes(by delta: Double) { + var settings = SettingsData(accessor: settingsStore) + + let editorSize = settings.textEditing.font.size + if (delta > 0 && editorSize < 288) || (delta < 0 && editorSize > 1) { + settings.textEditing.font.size = editorSize + delta + } + + let terminalSize = settings.terminal.font.size + if (delta > 0 && terminalSize < 288) || (delta < 0 && terminalSize > 1) { + settings.terminal.font.size = terminalSize + delta + } + } + + private func resetFontSizes() { + var settings = SettingsData(accessor: settingsStore) + settings.textEditing.font.size = 12 + settings.terminal.font.size = 12 + } + var body: some Commands { CommandGroup(after: .toolbar) { Button("Show Command Palette") { @@ -40,30 +89,19 @@ struct ViewCommands: Commands { Menu("Font Size") { Button("Increase") { - if editorFontSize < 288 { - editorFontSize += 1 - } - if terminalFontSize < 288 { - terminalFontSize += 1 - } + adjustFontSizes(by: 1) } .keyboardShortcut("+") Button("Decrease") { - if editorFontSize > 1 { - editorFontSize -= 1 - } - if terminalFontSize > 1 { - terminalFontSize -= 1 - } + adjustFontSizes(by: -1) } .keyboardShortcut("-") Divider() Button("Reset") { - editorFontSize = 12 - terminalFontSize = 12 + resetFontSizes() } .keyboardShortcut("0", modifiers: [.command, .control]) } @@ -80,11 +118,12 @@ struct ViewCommands: Commands { Divider() - Button("\(showEditorJumpBar ? "Hide" : "Show") Jump Bar") { - showEditorJumpBar.toggle() + Button("\(settings.general.showEditorJumpBar ? "Hide" : "Show") Jump Bar") { + var settings = SettingsData(accessor: settingsStore) + settings.general.showEditorJumpBar.toggle() } - Toggle("Dim editors without focus", isOn: $dimEditorsWithoutFocus) + Toggle("Dim editors without focus", isOn: binding(\.general.dimEditorsWithoutFocus)) Divider() @@ -109,7 +148,7 @@ extension ViewCommands { } var utilityAreaCollapsed: Bool { - windowController?.workspace?.utilityAreaModel?.isCollapsed ?? true + windowController?.utilityAreaModel.isCollapsed ?? true } var toolbarCollapsed: Bool { @@ -134,7 +173,7 @@ extension ViewCommands { .keyboardShortcut("i", modifiers: [.control, .command]) Button("\(utilityAreaCollapsed ? "Show" : "Hide") Utility Area") { - CommandManager.shared.executeCommand("open.drawer") + windowController?.dependencies.commandManager.executeCommand("open.drawer") } .disabled(windowController == nil) .keyboardShortcut("y", modifiers: [.shift, .command]) @@ -160,9 +199,9 @@ extension ViewCommands { var body: some View { Menu("Navigators", content: { - ForEach(Array(model.tabItems.prefix(9).enumerated()), id: \.element) { index, tab in + ForEach(Array(model.tabItems.prefix(9).enumerated()), id: \.element.id) { index, tab in Button(tab.title) { - model.setNavigatorTab(tab: tab) + model.selectedTabID = tab.id } .keyboardShortcut(KeyEquivalent(Character(String(index + 1)))) } diff --git a/CodeEdit/Features/WindowCommands/WindowCommands.swift b/CodeEdit/App/MenuBar/WindowCommands.swift similarity index 100% rename from CodeEdit/Features/WindowCommands/WindowCommands.swift rename to CodeEdit/App/MenuBar/WindowCommands.swift diff --git a/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift b/CodeEdit/App/MenuBar/WindowControllerPropertyWrapper.swift similarity index 91% rename from CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift rename to CodeEdit/App/MenuBar/WindowControllerPropertyWrapper.swift index ecd717e111..93e8c22ea5 100644 --- a/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift +++ b/CodeEdit/App/MenuBar/WindowControllerPropertyWrapper.swift @@ -6,6 +6,7 @@ // import AppKit +import CEEditor import SwiftUI import Combine @@ -33,6 +34,7 @@ struct UpdatingWindowController: DynamicProperty { box.controller } + @MainActor class WindowControllerBox: ObservableObject { public private(set) weak var controller: CodeEditWindowController? @@ -58,18 +60,18 @@ struct UpdatingWindowController: DynamicProperty { } .store(in: &cancellables) - controller?.workspace?.utilityAreaModel?.objectWillChange.sink { [weak self] in + controller?.utilityAreaModel.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) - let activeEditor = controller?.workspace?.editorManager?.activeEditor + let activeEditor = controller?.workspace?.editorManager.activeEditor activeEditor?.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) - controller?.workspace?.taskManager?.objectWillChange.sink { [weak self] in + controller?.workspace?.taskManager.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) diff --git a/CodeEdit/Utils/Extensions/NSApplication/NSApp+openWindow.swift b/CodeEdit/App/NSApp+openWindow.swift similarity index 100% rename from CodeEdit/Utils/Extensions/NSApplication/NSApp+openWindow.swift rename to CodeEdit/App/NSApp+openWindow.swift diff --git a/CodeEdit/SceneID.swift b/CodeEdit/App/SceneID.swift similarity index 100% rename from CodeEdit/SceneID.swift rename to CodeEdit/App/SceneID.swift diff --git a/CodeEdit/Features/Settings/SoftwareUpdater.swift b/CodeEdit/App/SoftwareUpdater.swift similarity index 100% rename from CodeEdit/Features/Settings/SoftwareUpdater.swift rename to CodeEdit/App/SoftwareUpdater.swift diff --git a/CodeEdit/WindowObserver.swift b/CodeEdit/App/WindowObserver.swift similarity index 98% rename from CodeEdit/WindowObserver.swift rename to CodeEdit/App/WindowObserver.swift index 530505f4c9..b21c91b5bc 100644 --- a/CodeEdit/WindowObserver.swift +++ b/CodeEdit/App/WindowObserver.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct WindowObserver: View { diff --git a/CodeEdit/App/WorkspaceLifecycle/ApplicationShutdownCoordinator.swift b/CodeEdit/App/WorkspaceLifecycle/ApplicationShutdownCoordinator.swift new file mode 100644 index 0000000000..a0610adc19 --- /dev/null +++ b/CodeEdit/App/WorkspaceLifecycle/ApplicationShutdownCoordinator.swift @@ -0,0 +1,67 @@ +// +// ApplicationShutdownCoordinator.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/04/26. +// + +import Foundation +import CodeEditCore + +/// Orchestrates application shutdown: saves workspace paths, checks for unsaved changes, +/// prompts the user to save, and terminates running tasks. +/// +/// Returns `true` if shutdown should proceed, `false` if the user cancelled. +/// Language server shutdown is handled separately by the caller (AppDelegate) +/// since it's async and tied to the NSApplication reply lifecycle. +@MainActor +final class ApplicationShutdownCoordinator { + + private let windowManager: WorkspaceWindowManaging + private let eventBus: EventBus + + init(windowManager: WorkspaceWindowManaging, eventBus: EventBus) { + self.windowManager = windowManager + self.eventBus = eventBus + } + + /// - Returns: `true` if the app should proceed with termination, `false` if the user cancelled. + func execute() -> Bool { + let workspaces = windowManager.openWorkspaces + + // Save workspace paths for recovery on next launch + let projects: [String] = workspaces.map { $0.fileURL.path } + UserDefaults.standard.set(projects, forKey: AppDelegate.recoverWorkspacesKey) + + // Check for unsaved changes and prompt the user + let hasUnsavedChanges = workspaces.contains { $0.hasUnsavedChanges() } + if hasUnsavedChanges { + for workspace in workspaces where !workspace.promptSaveUnsavedFiles() { + return false // User cancelled + } + } + + // Terminate all running tasks across workspaces + terminateTasks(in: workspaces) + + return true + } + + private func terminateTasks(in workspaces: [Workspace]) { + let taskManagers = workspaces.compactMap { $0.taskManager } + + if taskManagers.reduce(0, { $0 + $1.activeTasks.count }) > 0 { + let task = TaskNotificationModel( + id: "appdelegate.terminate_tasks", + title: "Terminating Tasks", + message: "Interrupting all running tasks before quitting...", + isLoading: true + ) + eventBus.publish(TaskNotificationEvent(.create(task))) + + taskManagers.forEach { $0.stopAllTasks() } + + eventBus.publish(TaskNotificationEvent(.delete(id: task.id))) + } + } +} diff --git a/CodeEdit/App/WorkspaceLifecycle/DocumentOpener.swift b/CodeEdit/App/WorkspaceLifecycle/DocumentOpener.swift new file mode 100644 index 0000000000..51b501f1c9 --- /dev/null +++ b/CodeEdit/App/WorkspaceLifecycle/DocumentOpener.swift @@ -0,0 +1,44 @@ +// +// DocumentOpener.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/04/26. +// + +import AppKit +import WelcomeWindow + +/// Routes a URL to the appropriate opener: a workspace (folder), an existing workspace's file, +/// or a standalone document via NSDocumentController. +@MainActor +final class DocumentOpener { + private let windowManager: WorkspaceWindowManaging + + init(windowManager: WorkspaceWindowManaging) { + self.windowManager = windowManager + } + + func execute(url: URL, onCompletion: @escaping () -> Void) { + do { + if url.isFolder { + // Folders are noted as recents in `WorkspaceWindowManager.openWorkspace`. + try windowManager.openWorkspace(at: url) + onCompletion() + } else if windowManager.openFileInWorkspace(url: url) { + RecentsStore.documentOpened(at: url) + onCompletion() + } else { + NSDocumentController.shared.openDocument( + withContentsOf: url, display: true + ) { _, _, error in + if error == nil { + RecentsStore.documentOpened(at: url) + onCompletion() + } + } + } + } catch { + NSAlert(error: error).runModal() + } + } +} diff --git a/CodeEdit/App/WorkspaceLifecycle/WorkspaceCloser.swift b/CodeEdit/App/WorkspaceLifecycle/WorkspaceCloser.swift new file mode 100644 index 0000000000..e052c865d2 --- /dev/null +++ b/CodeEdit/App/WorkspaceLifecycle/WorkspaceCloser.swift @@ -0,0 +1,25 @@ +// +// WorkspaceCloser.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/04/26. +// + +import CELSP +import Foundation + +/// Coordinates cleanup when a workspace is closed (LSP shutdown + workspace teardown). +@MainActor +final class WorkspaceCloser { + + private let lspService: any LSPServiceProtocol + + init(lspService: any LSPServiceProtocol) { + self.lspService = lspService + } + + func execute(workspace: Workspace) { + lspService.closeWorkspace(workspace.fileURL.absoluteURL.path()) + workspace.tearDown() + } +} diff --git a/CodeEdit/App/WorkspaceLifecycle/WorkspaceOpener.swift b/CodeEdit/App/WorkspaceLifecycle/WorkspaceOpener.swift new file mode 100644 index 0000000000..a237f4bdff --- /dev/null +++ b/CodeEdit/App/WorkspaceLifecycle/WorkspaceOpener.swift @@ -0,0 +1,54 @@ +// +// WorkspaceOpener.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/04/26. +// + +import AppKit + +/// Creates and configures a workspace, window, and window controller for a given URL. +@MainActor +final class WorkspaceOpener { + private let dependencies: AppDependencies + + init(dependencies: AppDependencies) { + self.dependencies = dependencies + } + + struct Result { + let workspace: Workspace + let window: NSWindow + let windowController: CodeEditWindowController + } + + func execute(url: URL) -> Result { + let workspace = WorkspaceFactory.make(url: url, dependencies: dependencies) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1400, height: 900), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + let windowController = CodeEditWindowController( + window: window, + workspace: workspace, + dependencies: dependencies + ) + + // Restore saved window geometry, or use default centered frame + if let rectString = workspace.statePersistence.get(.workspaceWindowSize) as? String { + window.setFrame(NSRectFromString(rectString), display: true, animate: false) + } else { + window.setFrame(NSRect(x: 0, y: 0, width: 1400, height: 900), display: true, animate: false) + window.center() + } + + window.setAccessibilityIdentifier("workspace") + window.setAccessibilityDocument(workspace.fileURL.absoluteString) + + return Result(workspace: workspace, window: window, windowController: windowController) + } +} diff --git a/CodeEdit/App/WorkspaceLifecycle/WorkspaceWindowManager.swift b/CodeEdit/App/WorkspaceLifecycle/WorkspaceWindowManager.swift new file mode 100644 index 0000000000..707f31f659 --- /dev/null +++ b/CodeEdit/App/WorkspaceLifecycle/WorkspaceWindowManager.swift @@ -0,0 +1,225 @@ +// +// WorkspaceWindowManager.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 06.04.26. +// + +import AppKit +import CodeEditSettings +import CEWorkspaceFileManager +import CodeEditCore +import CENotifications +import SwiftUI +import WelcomeWindow + +/// Manages the lifecycle of workspace windows, replacing NSDocumentController for workspace management. +@MainActor +final class WorkspaceWindowManager: WorkspaceWindowManaging { + + private let dependencies: AppDependencies + private var eventBus: EventBus { dependencies.eventBus } + + private let workspaceOpener: WorkspaceOpener + private let workspaceCloser: WorkspaceCloser + private lazy var documentOpener = DocumentOpener(windowManager: self) + + init(dependencies: AppDependencies) { + self.dependencies = dependencies + self.workspaceOpener = WorkspaceOpener(dependencies: dependencies) + self.workspaceCloser = WorkspaceCloser(lspService: dependencies.lspService) + } + + /// All currently open workspaces. + private(set) var openWorkspaces: [Workspace] = [] + + /// Maps workspaces to their window controllers for lookup. + private var windowControllers: [ObjectIdentifier: CodeEditWindowController] = [:] + + // MARK: - Open Workspace + + func openWorkspace(at url: URL) throws { + // Check if this workspace is already open + if let existing = openWorkspaces.first(where: { $0.fileURL.standardizedFileURL == url.standardizedFileURL }) { + focusWorkspace(existing) + return + } + + let result = workspaceOpener.execute(url: url) + + openWorkspaces.append(result.workspace) + windowControllers[ObjectIdentifier(result.workspace)] = result.windowController + let notificationPanel = result.windowController.notificationPanel + notificationPanel.windowController = result.windowController + // App shell owns window-toolbar mutation; the package signals a refresh via this hook. + notificationPanel.onToolbarUpdateRequested = { [weak notificationPanel] in + notificationPanel?.updateToolbarItem() + } + + result.window.makeKeyAndOrderFront(nil) + + RecentsStore.documentOpened(at: url) + } + + // MARK: - Close Workspace + + func closeWorkspace(_ workspace: Workspace) { + workspaceCloser.execute(workspace: workspace) + + let id = ObjectIdentifier(workspace) + windowControllers.removeValue(forKey: id) + openWorkspaces.removeAll { $0 === workspace } + + if openWorkspaces.isEmpty { + handleLastWorkspaceClosed() + } + } + + // MARK: - Query + + func workspace(containing url: URL) -> Workspace? { + openWorkspaces.first { workspace in + workspace.workspaceFileManager.getFile(url.absolutePath, createIfNotFound: true) != nil + } + } + + /// Attempts to open a file URL in an existing workspace, finding the nearest workspace. + /// Returns `true` if the file was opened in a workspace. + func openFileInWorkspace(url: URL, asTemporary: Bool) -> Bool { + guard !url.isFolder else { return false } + + for workspace in openWorkspaces.sorted(by: { + $0.fileURL.sharedComponents(url) > $1.fileURL.sharedComponents(url) + }) { + if let newFile = workspace.workspaceFileManager.getFile(url.absolutePath, createIfNotFound: true) { + workspace.editorManager.openTab(item: newFile, asTemporary: asTemporary) + focusWorkspace(workspace) + return true + } + } + return false + } + + // MARK: - Window Controller Access + + func windowController(for workspace: Workspace) -> CodeEditWindowController? { + windowControllers[ObjectIdentifier(workspace)] + } + + // MARK: - Open Panel + + func openDocumentFromPanel() { + let dialog = NSOpenPanel() + dialog.title = "Open Workspace or File" + dialog.showsResizeIndicator = true + dialog.showsHiddenFiles = false + dialog.canChooseFiles = true + dialog.canChooseDirectories = true + + dialog.begin { [weak self] result in + guard let self, result == .OK, let url = dialog.url else { return } + self.openDocument(at: url, onCompletion: {}) + } + } + + func newDocumentFromPanel() { + let panel = NSSavePanel() + guard panel.runModal() == .OK, let url = panel.url else { return } + + let created = FileManager.default.createFile( + atPath: url.path, + contents: nil, + attributes: [FileAttributeKey.creationDate: Date()] + ) + guard created else { + print("Failed to create new document") + return + } + + if !openFileInWorkspace(url: url) { + NSDocumentController.shared.openDocument( + withContentsOf: url, + display: true + ) { _, _, error in + if let error { NSAlert(error: error).runModal() } + } + } + } + + // MARK: - Convenience Openers + + /// Opens a workspace or file at the given URL, calling the completion handler on success. + func openDocument(at url: URL, onCompletion: @escaping () -> Void) { + documentOpener.execute(url: url, onCompletion: onCompletion) + } + + /// Opens a dialog to choose a file or folder, with optional configuration. + func openDocumentWithDialog( + canChooseFiles: Bool = true, + canChooseDirectories: Bool = true, + onDialogPresented: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + let dialog = NSOpenPanel() + dialog.title = "Open Workspace or File" + dialog.showsResizeIndicator = true + dialog.showsHiddenFiles = false + dialog.canChooseFiles = canChooseFiles + dialog.canChooseDirectories = canChooseDirectories + + onDialogPresented?() + + dialog.begin { [weak self] result in + guard let self else { return } + if result == .OK, let url = dialog.url { + self.openDocument(at: url, onCompletion: {}) + } else if result == .cancel { + onCancel?() + } + } + } + + // MARK: - Private + + private func focusWorkspace(_ workspace: Workspace) { + if let controller = windowControllers[ObjectIdentifier(workspace)] { + controller.window?.makeKeyAndOrderFront(nil) + } + } + + private func handleLastWorkspaceClosed() { + switch dependencies.settingsAccessor.value(GeneralSettings.self).reopenWindowAfterClose { + case .showWelcomeWindow: + if let welcomeWindow = NSApp.findWindow(.welcome) { + welcomeWindow.makeKeyAndOrderFront(nil) + } else { + // Publish event for AppDelegate to open the welcome window via SwiftUI's openWindow + eventBus.publish(WelcomeWindowRequestedEvent()) + } + case .quit: + NSApplication.shared.terminate(nil) + case .doNothing: + break + } + } +} + +extension URL { + /// Compares this url with another, counting the number of shared path components. Stops counting once a + /// different component is found. + /// + /// - Note: URL treats a leading `/` as a component, so `/Users` and `/` will return `1`. + /// - Parameter other: The URL to compare against. + /// - Returns: The number of shared components. + func sharedComponents(_ other: URL) -> Int { + var count = 0 + for (component, otherComponent) in zip(pathComponents, other.pathComponents) { + if component == otherComponent { + count += 1 + } else { + return count + } + } + return count + } +} diff --git a/CodeEdit/App/WorkspaceLifecycle/WorkspaceWindowManaging.swift b/CodeEdit/App/WorkspaceLifecycle/WorkspaceWindowManaging.swift new file mode 100644 index 0000000000..c134780420 --- /dev/null +++ b/CodeEdit/App/WorkspaceLifecycle/WorkspaceWindowManaging.swift @@ -0,0 +1,40 @@ +// +// WorkspaceWindowManaging.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 06.04.26. +// + +import Foundation + +/// Protocol for workspace window lifecycle management. +/// Enables testability by abstracting the window manager. +protocol WorkspaceWindowManaging: AnyObject { + var openWorkspaces: [Workspace] { get } + func openWorkspace(at url: URL) throws + func closeWorkspace(_ workspace: Workspace) + func workspace(containing url: URL) -> Workspace? + func openFileInWorkspace(url: URL, asTemporary: Bool) -> Bool + + /// Presents an open panel and opens the chosen workspace or file. + func openDocumentFromPanel() + /// Presents a save panel, creates the file, and opens it. + func newDocumentFromPanel() + /// Opens a workspace or file at the given URL, calling the completion handler on success. + func openDocument(at url: URL, onCompletion: @escaping () -> Void) + /// Opens a dialog to choose a file or folder. (The concrete implementation + /// provides default argument values; protocol requirements cannot.) + func openDocumentWithDialog( + canChooseFiles: Bool, + canChooseDirectories: Bool, + onDialogPresented: (() -> Void)?, + onCancel: (() -> Void)? + ) +} + +extension WorkspaceWindowManaging { + /// Convenience: open non-temporarily. Keeps existing `openFileInWorkspace(url:)` call sites working. + func openFileInWorkspace(url: URL) -> Bool { + openFileInWorkspace(url: url, asTemporary: false) + } +} diff --git a/CodeEdit/Utils/withTimeout.swift b/CodeEdit/App/withTimeout.swift similarity index 98% rename from CodeEdit/Utils/withTimeout.swift rename to CodeEdit/App/withTimeout.swift index 9db61b69b4..38f3b23430 100644 --- a/CodeEdit/Utils/withTimeout.swift +++ b/CodeEdit/App/withTimeout.swift @@ -1,5 +1,5 @@ // -// TimedOutError.swift +// withTimeout.swift // CodeEdit // // Created by Khan Winter on 7/8/25. diff --git a/CodeEdit/Features/About/AboutFooterView.swift b/CodeEdit/AuxiliaryWindows/About/AboutFooterView.swift similarity index 100% rename from CodeEdit/Features/About/AboutFooterView.swift rename to CodeEdit/AuxiliaryWindows/About/AboutFooterView.swift diff --git a/CodeEdit/Features/About/AboutSubtitleView.swift b/CodeEdit/AuxiliaryWindows/About/AboutSubtitleView.swift similarity index 100% rename from CodeEdit/Features/About/AboutSubtitleView.swift rename to CodeEdit/AuxiliaryWindows/About/AboutSubtitleView.swift diff --git a/CodeEdit/Features/About/Acknowledgements/Views/AcknowledgementRowView.swift b/CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementRowView.swift similarity index 96% rename from CodeEdit/Features/About/Acknowledgements/Views/AcknowledgementRowView.swift rename to CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementRowView.swift index bbb9338260..a671d4d256 100644 --- a/CodeEdit/Features/About/Acknowledgements/Views/AcknowledgementRowView.swift +++ b/CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementRowView.swift @@ -1,5 +1,5 @@ // -// AcknowledgementsRowView.swift +// AcknowledgementRowView.swift // CodeEdit // // Created by Austin Condiff on 1/19/23. diff --git a/CodeEdit/Features/About/Acknowledgements/Views/AcknowledgementsView.swift b/CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementsView.swift similarity index 100% rename from CodeEdit/Features/About/Acknowledgements/Views/AcknowledgementsView.swift rename to CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementsView.swift diff --git a/CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift b/CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementsViewModel.swift similarity index 97% rename from CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift rename to CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementsViewModel.swift index b4958a4167..9b3490c257 100644 --- a/CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift +++ b/CodeEdit/AuxiliaryWindows/About/Acknowledgements/AcknowledgementsViewModel.swift @@ -1,5 +1,5 @@ // -// AcknowledgementsModel.swift +// AcknowledgementsViewModel.swift // CodeEditModules/Acknowledgements // // Created by Lukas Pistrol on 01.05.22. diff --git a/CodeEdit/Features/About/Acknowledgements/Views/ParsePackagesResolved.swift b/CodeEdit/AuxiliaryWindows/About/Acknowledgements/ParsePackagesResolved.swift similarity index 100% rename from CodeEdit/Features/About/Acknowledgements/Views/ParsePackagesResolved.swift rename to CodeEdit/AuxiliaryWindows/About/Acknowledgements/ParsePackagesResolved.swift diff --git a/CodeEdit/Features/About/Contributors/Model/Contributor.swift b/CodeEdit/AuxiliaryWindows/About/Contributors/Contributor.swift similarity index 100% rename from CodeEdit/Features/About/Contributors/Model/Contributor.swift rename to CodeEdit/AuxiliaryWindows/About/Contributors/Contributor.swift diff --git a/CodeEdit/Features/About/Contributors/ContributorRowView.swift b/CodeEdit/AuxiliaryWindows/About/Contributors/ContributorRowView.swift similarity index 100% rename from CodeEdit/Features/About/Contributors/ContributorRowView.swift rename to CodeEdit/AuxiliaryWindows/About/Contributors/ContributorRowView.swift diff --git a/CodeEdit/Features/About/Contributors/ContributorsView.swift b/CodeEdit/AuxiliaryWindows/About/Contributors/ContributorsView.swift similarity index 100% rename from CodeEdit/Features/About/Contributors/ContributorsView.swift rename to CodeEdit/AuxiliaryWindows/About/Contributors/ContributorsView.swift diff --git a/CodeEdit/Utils/Extensions/OperatingSystemVersion/OperatingSystemVersion+String.swift b/CodeEdit/AuxiliaryWindows/About/OperatingSystemVersion+String.swift similarity index 100% rename from CodeEdit/Utils/Extensions/OperatingSystemVersion/OperatingSystemVersion+String.swift rename to CodeEdit/AuxiliaryWindows/About/OperatingSystemVersion+String.swift diff --git a/CodeEdit/Features/Extensions/Commands+ForEach.swift b/CodeEdit/AuxiliaryWindows/Extensions/Commands+ForEach.swift similarity index 100% rename from CodeEdit/Features/Extensions/Commands+ForEach.swift rename to CodeEdit/AuxiliaryWindows/Extensions/Commands+ForEach.swift diff --git a/CodeEdit/Features/Extensions/ExtensionActivatorView.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionActivatorView.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionActivatorView.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionActivatorView.swift diff --git a/CodeEdit/Features/Extensions/ExtensionDetailView.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionDetailView.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionDetailView.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionDetailView.swift diff --git a/CodeEdit/Features/Extensions/ExtensionDiscovery.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionDiscovery.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionDiscovery.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionDiscovery.swift diff --git a/CodeEdit/Features/Extensions/ExtensionInfo.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionInfo.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionInfo.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionInfo.swift diff --git a/CodeEdit/Features/Extensions/ExtensionManagerWindow.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionManagerWindow.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionManagerWindow.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionManagerWindow.swift diff --git a/CodeEdit/Features/Extensions/ExtensionSceneView.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionSceneView.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionSceneView.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionSceneView.swift diff --git a/CodeEdit/Features/Extensions/ExtensionsListView.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionsListView.swift similarity index 100% rename from CodeEdit/Features/Extensions/ExtensionsListView.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionsListView.swift diff --git a/CodeEdit/Features/Extensions/ExtensionsManager.swift b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionsManager.swift similarity index 93% rename from CodeEdit/Features/Extensions/ExtensionsManager.swift rename to CodeEdit/AuxiliaryWindows/Extensions/ExtensionsManager.swift index d539e1ac6d..07db69aabc 100644 --- a/CodeEdit/Features/Extensions/ExtensionsManager.swift +++ b/CodeEdit/AuxiliaryWindows/Extensions/ExtensionsManager.swift @@ -1,5 +1,5 @@ // -// ExtensionManager.swift +// ExtensionsManager.swift // CodeEdit // // Created by Wouter Hennen on 30/12/2022. diff --git a/CodeEdit/Features/Extensions/codeedit.extension.appextensionpoint b/CodeEdit/AuxiliaryWindows/Extensions/codeedit.extension.appextensionpoint similarity index 100% rename from CodeEdit/Features/Extensions/codeedit.extension.appextensionpoint rename to CodeEdit/AuxiliaryWindows/Extensions/codeedit.extension.appextensionpoint diff --git a/CodeEdit/Features/Feedback/Model/FeedbackIssueArea.swift b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackIssueArea.swift similarity index 100% rename from CodeEdit/Features/Feedback/Model/FeedbackIssueArea.swift rename to CodeEdit/AuxiliaryWindows/Feedback/FeedbackIssueArea.swift diff --git a/CodeEdit/Features/Feedback/Model/FeedbackModel.swift b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackModel.swift similarity index 90% rename from CodeEdit/Features/Feedback/Model/FeedbackModel.swift rename to CodeEdit/AuxiliaryWindows/Feedback/FeedbackModel.swift index 589e762694..dce2d3fe06 100644 --- a/CodeEdit/Features/Feedback/Model/FeedbackModel.swift +++ b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackModel.swift @@ -5,7 +5,9 @@ // Created by Nanashi Li on 2022/04/14. // +import CESourceControl import SwiftUI +import CodeEditSettings public class FeedbackModel: ObservableObject { @@ -13,6 +15,10 @@ public class FeedbackModel: ObservableObject { private let keychain = CodeEditKeychain() + /// The settings store. Property-injected by `AppDelegate` at launch — see `ThemeModel`'s + /// `settingsAccessor` for why these pre-existing singletons take their store this way. + public var settingsAccessor: SettingsAccessing = DefaultSettingsReader() + @Environment(\.openURL) var openIssueURL @@ -138,7 +144,7 @@ public class FeedbackModel: ObservableObject { expectation: String?, actuallyHappened: String? ) { - let gitAccounts = Settings[\.accounts].sourceControlAccounts.gitAccounts + let gitAccounts = settingsAccessor.value(AccountsSettings.self).sourceControlAccounts.gitAccounts let firstGitAccount = gitAccounts.first let config = GitHubTokenConfiguration(keychain.get(firstGitAccount!.name)) @@ -157,7 +163,7 @@ public class FeedbackModel: ObservableObject { ) { response in switch response { case .success(let issue): - if Settings[\.sourceControl].general.openFeedbackInBrowser { + if self.settingsAccessor.value(SourceControlSettings.self).general.openFeedbackInBrowser { self.openIssueURL(issue.htmlURL ?? URL(string: "https://github.com/CodeEditApp/CodeEdit/issues")!) } self.isSubmitted.toggle() diff --git a/CodeEdit/Features/Feedback/HelperView/FeedbackToolbar.swift b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackToolbar.swift similarity index 100% rename from CodeEdit/Features/Feedback/HelperView/FeedbackToolbar.swift rename to CodeEdit/AuxiliaryWindows/Feedback/FeedbackToolbar.swift diff --git a/CodeEdit/Features/Feedback/Model/FeedbackType.swift b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackType.swift similarity index 100% rename from CodeEdit/Features/Feedback/Model/FeedbackType.swift rename to CodeEdit/AuxiliaryWindows/Feedback/FeedbackType.swift diff --git a/CodeEdit/Features/Feedback/FeedbackView.swift b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackView.swift similarity index 96% rename from CodeEdit/Features/Feedback/FeedbackView.swift rename to CodeEdit/AuxiliaryWindows/Feedback/FeedbackView.swift index 03586cc845..102a657fb4 100644 --- a/CodeEdit/Features/Feedback/FeedbackView.swift +++ b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackView.swift @@ -5,6 +5,8 @@ // Created by Nanashi Li on 2022/04/14. // +import CodeEditSettings +import CodeEditUI import SwiftUI struct FeedbackView: View { @@ -210,7 +212,11 @@ struct FeedbackView: View { } } - func showWindow() { - FeedbackWindowController(view: self, size: NSSize(width: 1028, height: 762)).showWindow(nil) + func showWindow(settingsStore: PersistentSettingsStore) { + FeedbackWindowController( + view: self, + size: NSSize(width: 1028, height: 762), + settingsStore: settingsStore + ).showWindow(nil) } } diff --git a/CodeEdit/Features/Feedback/Controllers/FeedbackWindowController.swift b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackWindowController.swift similarity index 89% rename from CodeEdit/Features/Feedback/Controllers/FeedbackWindowController.swift rename to CodeEdit/AuxiliaryWindows/Feedback/FeedbackWindowController.swift index 824125d3bd..aa67d73454 100644 --- a/CodeEdit/Features/Feedback/Controllers/FeedbackWindowController.swift +++ b/CodeEdit/AuxiliaryWindows/Feedback/FeedbackWindowController.swift @@ -5,11 +5,12 @@ // Created by Nanashi Li on 2022/04/14. // +import CodeEditSettings import SwiftUI final class FeedbackWindowController: NSWindowController, NSToolbarDelegate { - convenience init(view: T, size: NSSize) { - let hostingController = NSHostingController(rootView: SettingsInjector { view }) + convenience init(view: T, size: NSSize, settingsStore: PersistentSettingsStore) { + let hostingController = NSHostingController(rootView: SettingsInjector(store: settingsStore) { view }) let window = NSWindow(contentViewController: hostingController) self.init(window: window) window.title = "Feedback for CodeEdit" diff --git a/CodeEdit/AuxiliaryWindows/Settings/AppSettings.swift b/CodeEdit/AuxiliaryWindows/Settings/AppSettings.swift new file mode 100644 index 0000000000..97e4f3b551 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/AppSettings.swift @@ -0,0 +1,58 @@ +// +// AppSettings.swift +// CodeEdit +// +// Created by Wouter Hennen on 12/04/2023. +// + +import SwiftUI +import CodeEditSettings + +/// Reads and writes one setting inside a SwiftUI view, addressed through the app-wide +/// ``SettingsData`` aggregate. +/// +/// The legacy app-side counterpart of ``SettingsValue``. Both now resolve the same way — by +/// observing the injected `PersistentSettingsStore` — so a view can use either without a difference +/// in behaviour. The distinction +/// that remains is what they may name: `SettingsValue` names one section and works inside feature +/// packages, `AppSettings` names the app-wide aggregate and therefore cannot. New code should prefer +/// `SettingsValue`; this wrapper exists so its existing declarations — 47 of them, across 30 +/// app-target files — did not all have to move in one change. +/// +/// **Only valid inside a `View`.** It used to read a singleton, so it also worked in models and +/// AppKit types; it no longer does, and such a use traps at runtime because no store was injected. +/// Non-view types take a ``SettingsReading``/``SettingsAccessing`` by initializer instead. +/// +/// A `Commands` conformer counts as a non-view type here: `.commands { }` is attached beside a +/// scene's content rather than inside it, so the environment is not documented to reach it. See +/// `CodeEditCommands`, which is handed the store by initializer. +@propertyWrapper +struct AppSettings: DynamicProperty where T: Equatable { + + @EnvironmentObject private var store: PersistentSettingsStore + + private let keyPath: WritableKeyPath + + init(_ keyPath: WritableKeyPath) { + self._store = EnvironmentObject() + self.keyPath = keyPath + } + + var wrappedValue: T { + get { SettingsData(accessor: store)[keyPath: keyPath] } + nonmutating set { + // `SettingsData` is a stateless façade, so this "local" mutation writes straight through + // to the accessor — the section is read, the field replaced, the section written back. + var settings = SettingsData(accessor: store) + settings[keyPath: keyPath] = newValue + } + } + + var projectedValue: Binding { + Binding { + wrappedValue + } set: { + wrappedValue = $0 + } + } +} diff --git a/CodeEdit/Features/Settings/Views/ExternalLink.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/ExternalLink.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/ExternalLink.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/ExternalLink.swift diff --git a/CodeEdit/Features/Settings/Views/FontWeightPicker.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/FontWeightPicker.swift similarity index 97% rename from CodeEdit/Features/Settings/Views/FontWeightPicker.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/FontWeightPicker.swift index 92b50fccfb..6720acfa08 100644 --- a/CodeEdit/Features/Settings/Views/FontWeightPicker.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Controls/FontWeightPicker.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings struct FontWeightPicker: View { @Binding var selection: NSFont.Weight diff --git a/CodeEdit/Features/Settings/Views/GlobPatternList.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/GlobPatternList.swift similarity index 98% rename from CodeEdit/Features/Settings/Views/GlobPatternList.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/GlobPatternList.swift index 80f216a4b8..3d1e17a234 100644 --- a/CodeEdit/Features/Settings/Views/GlobPatternList.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Controls/GlobPatternList.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditUI +import CodeEditSettings struct GlobPatternList: View { @Binding var patterns: [GlobPattern] diff --git a/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/GlobPatternListItem.swift similarity index 98% rename from CodeEdit/Features/Settings/Views/GlobPatternListItem.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/GlobPatternListItem.swift index f6d4d91a3d..d3c161d531 100644 --- a/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Controls/GlobPatternListItem.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings struct GlobPatternListItem: View { @Binding var pattern: GlobPattern diff --git a/CodeEdit/Utils/Extensions/Int/Int+HexString.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/Int+HexString.swift similarity index 100% rename from CodeEdit/Utils/Extensions/Int/Int+HexString.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/Int+HexString.swift diff --git a/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/InvisibleCharacterWarningList.swift similarity index 94% rename from CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/InvisibleCharacterWarningList.swift index cf7bd58f20..6699fcbc24 100644 --- a/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Controls/InvisibleCharacterWarningList.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditSettings +import CodeEditUI struct InvisibleCharacterWarningList: View { @Binding var items: [UInt16: String] @@ -40,7 +42,7 @@ struct InvisibleCharacterWarningList: View { Button { // Add defaults without removing user's data. We do still override notes here. items = items.merging( - SettingsData.TextEditingSettings.WarningCharacters.default.characters, + TextEditingSettings.WarningCharacters.default.characters, uniquingKeysWith: { _, defaults in defaults } diff --git a/CodeEdit/Features/Settings/Views/MonospacedFontPicker.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/MonospacedFontPicker.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/MonospacedFontPicker.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/MonospacedFontPicker.swift diff --git a/CodeEdit/Features/Settings/Views/SettingsColorPicker.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/SettingsColorPicker.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/SettingsColorPicker.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/SettingsColorPicker.swift diff --git a/CodeEdit/Features/Settings/Views/WarningCharactersView.swift b/CodeEdit/AuxiliaryWindows/Settings/Controls/WarningCharactersView.swift similarity index 93% rename from CodeEdit/Features/Settings/Views/WarningCharactersView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Controls/WarningCharactersView.swift index bc2c21133b..ab72f6d0bd 100644 --- a/CodeEdit/Features/Settings/Views/WarningCharactersView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Controls/WarningCharactersView.swift @@ -6,9 +6,10 @@ // import SwiftUI +import CodeEditSettings struct WarningCharactersView: View { - typealias Config = SettingsData.TextEditingSettings.WarningCharacters + typealias Config = TextEditingSettings.WarningCharacters @Binding var warningCharacters: Config diff --git a/CodeEdit/AuxiliaryWindows/Settings/KeybindingsSettings+Reconcile.swift b/CodeEdit/AuxiliaryWindows/Settings/KeybindingsSettings+Reconcile.swift new file mode 100644 index 0000000000..acc8be1fed --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/KeybindingsSettings+Reconcile.swift @@ -0,0 +1,25 @@ +// +// KeybindingsSettings+Reconcile.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import CodeEditSettings +import CodeEditCore + +extension KeybindingsSettings { + /// Merges bundled-default keybindings (from `default_keybindings.json`, owned by + /// `KeybindingManager`) into the persisted settings, adding only keys the user does + /// not already have. Preserves user overrides. Invoked once at app startup — + /// previously ran as a side effect of decoding `KeybindingsSettings`. + static func reconcileDefaults(keybindingManager: KeybindingManaging, settings: SettingsAccessing) { + let defaults = keybindingManager.keyboardShortcuts + var section = settings.value(KeybindingsSettings.self) + for (key, _) in defaults where section.keybindings[key] == nil { + section.keybindings[key] = keybindingManager.named(with: key) + } + settings.setValue(section) + } +} diff --git a/CodeEdit/Features/Settings/Models/PageAndSettings.swift b/CodeEdit/AuxiliaryWindows/Settings/PageAndSettings.swift similarity index 78% rename from CodeEdit/Features/Settings/Models/PageAndSettings.swift rename to CodeEdit/AuxiliaryWindows/Settings/PageAndSettings.swift index 3297fcb06d..f635266f4b 100644 --- a/CodeEdit/Features/Settings/Models/PageAndSettings.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/PageAndSettings.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditSettings struct PageAndSettings: Identifiable, Equatable { let id: UUID = UUID() @@ -14,6 +15,6 @@ struct PageAndSettings: Identifiable, Equatable { init(_ page: SettingsPage) { self.page = page - self.settings = SettingsData().propertiesOf(page.name) + self.settings = SettingsPage.propertiesOf(page.name) } } diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountSelectionView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountSelectionView.swift similarity index 95% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/AccountSelectionView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountSelectionView.swift index 0437b716d5..8a8294b35b 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountSelectionView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountSelectionView.swift @@ -1,11 +1,13 @@ // -// AccoundSelectionView.swift +// AccountSelectionView.swift // CodeEdit // // Created by Austin Condiff on 4/5/23. // import SwiftUI +import CESourceControl +import CodeEditSettings struct AccountSelectionView: View { @Environment(\.dismiss) diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift similarity index 92% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift index d655a8d16e..e6ee9f7463 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CESourceControl +import CodeEditSettings +import CodeEditUI struct AccountsSettingsAccountLink: View { @Binding var account: SourceControlAccount diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsDetailsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsDetailsView.swift similarity index 98% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsDetailsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsDetailsView.swift index 52596cfcef..eaeb6f4396 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsDetailsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsDetailsView.swift @@ -1,11 +1,13 @@ // -// AccountsSettingsDetailView.swift +// AccountsSettingsDetailsView.swift // CodeEdit // // Created by Austin Condiff on 4/6/23. // import SwiftUI +import CESourceControl +import CodeEditSettings struct AccountsSettingsDetailsView: View { @Environment(\.dismiss) diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift similarity index 95% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift index cc9c687dcf..fde8b97c3f 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift @@ -1,11 +1,12 @@ // -// AccoundsSettingsAccountRow.swift +// AccountsSettingsProviderRow.swift // CodeEdit // // Created by Austin Condiff on 4/5/23. // import SwiftUI +import CodeEditUI struct AccountsSettingsProviderRow: View { var name: String diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift similarity index 99% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift index 1b648057c0..35cd75cff6 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift @@ -5,7 +5,10 @@ // Created by Austin Condiff on 4/5/23. // +import CESourceControl import SwiftUI +import CodeEditSettings +import CodeEditUI struct AccountsSettingsSigninView: View { @Environment(\.dismiss) diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsView.swift similarity index 96% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsView.swift index 92926889c6..7d2490a3c2 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/AccountsSettingsView.swift @@ -1,11 +1,13 @@ // -// AccountSettingsView.swift +// AccountsSettingsView.swift // CodeEdit // // Created by Austin Condiff on 4/4/23. // import SwiftUI +import CESourceControl +import CodeEditSettings struct AccountsSettingsView: View { @AppSettings(\.accounts.sourceControlAccounts.gitAccounts) diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/CreateSSHKeyView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/CreateSSHKeyView.swift similarity index 100% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/CreateSSHKeyView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/CreateSSHKeyView.swift diff --git a/CodeEdit/Utils/Extensions/Text/Font+Caption3.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/Font+Caption3.swift similarity index 100% rename from CodeEdit/Utils/Extensions/Text/Font+Caption3.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/Font+Caption3.swift diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/SourceControlAccount+Icon.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/SourceControlAccount+Icon.swift new file mode 100644 index 0000000000..b60e8f329c --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/AccountsSettings/SourceControlAccount+Icon.swift @@ -0,0 +1,25 @@ +// +// SourceControlAccount+Icon.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import SwiftUI +import CESourceControl +import CodeEditSettings + +extension SourceControlAccount.Provider { + /// The provider's icon from the app asset catalog. Lives app-side because + /// `ImageResource` symbols are generated in the app target, not the package. + var iconResource: ImageResource { + switch self { + case .bitbucketCloud, .bitbucketServer: + return .bitBucketIcon + case .github, .githubEnterprise: + return .gitHubIcon + case .gitlab, .gitlabSelfHosted: + return .gitLabIcon + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift similarity index 96% rename from CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift index 0b1bcf0ba4..7585e385d0 100644 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift @@ -5,7 +5,10 @@ // Created by Abe Malla on 5/16/24. // +import CELSP import SwiftUI +import CodeEditSettings +import CodeEditUI import LanguageServerProtocol /// A view that implements the Developer settings section diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerInstallView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServerInstallView.swift similarity index 94% rename from CodeEdit/Features/Settings/Pages/Extensions/LanguageServerInstallView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServerInstallView.swift index 7562668c65..3e39632c6b 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerInstallView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServerInstallView.swift @@ -5,13 +5,20 @@ // Created by Khan Winter on 8/14/25. // +import CELSP import SwiftUI +import CodeEditSettings +import CodeEditUI /// A view for initiating a package install and monitoring progress. struct LanguageServerInstallView: View { @Environment(\.dismiss) var dismiss - @EnvironmentObject private var registryManager: RegistryManager + @Environment(\.registryManager) + private var registryManager + + @AppSettings(\.languageServers.installedLanguageServers) + private var installedLanguageServers @ObservedObject var operation: PackageManagerInstallOperation @@ -28,7 +35,7 @@ struct LanguageServerInstallView: View { presenting: operation.waitingForConfirmation ) { _ in Button("Cancel") { - registryManager.cancelInstallation() + registryManager?.cancelInstallation() } Button("Continue") { operation.confirmCurrentStep() @@ -65,7 +72,7 @@ struct LanguageServerInstallView: View { .buttonStyle(.bordered) Button { do { - try registryManager.startInstallation(operation: operation) + try registryManager?.startInstallation(operation: operation) } catch { // Display the error NSAlert(error: error).runModal() @@ -76,7 +83,7 @@ struct LanguageServerInstallView: View { .buttonStyle(.borderedProminent) case .running: Button { - registryManager.cancelInstallation() + registryManager?.cancelInstallation() dismiss() } label: { Text("Cancel") @@ -148,7 +155,7 @@ struct LanguageServerInstallView: View { @ViewBuilder private var progressSection: some View { Section { LabeledContent("Step") { - if registryManager.installedLanguageServers[operation.package.name] != nil { + if installedLanguageServers[operation.package.name] != nil { HStack(spacing: 4) { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServerRowView.swift similarity index 91% rename from CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServerRowView.swift index 02f423f8a0..19120aa533 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServerRowView.swift @@ -5,7 +5,11 @@ // Created by Abe Malla on 2/2/25. // +import CodeEditUI +import CELSP import SwiftUI +import CodeEditSettings +import CodeEditCore private let iconSize: CGFloat = 26 @@ -15,10 +19,10 @@ struct LanguageServerRowView: View, Equatable { let onInstall: (() async -> Void) private var isInstalled: Bool { - registryManager.installedLanguageServers[package.name] != nil + installedLanguageServers[package.name] != nil } private var isEnabled: Bool { - registryManager.installedLanguageServers[package.name]?.isEnabled ?? false + installedLanguageServers[package.name]?.isEnabled ?? false } @State private var isHovering: Bool = false @@ -29,7 +33,13 @@ struct LanguageServerRowView: View, Equatable { @State private var showMore: Bool = false - @EnvironmentObject var registryManager: RegistryManager + @EnvironmentObject var registryState: RegistryViewState + + @Environment(\.registryManager) + private var registryManager + + @AppSettings(\.languageServers.installedLanguageServers) + private var installedLanguageServers init( package: RegistryItem, @@ -121,7 +131,7 @@ struct LanguageServerRowView: View, Equatable { private func installationButton() -> some View { if isInstalled { installedRow() - } else if registryManager.runningInstall?.package.name == package.name { + } else if registryState.runningInstall?.package.name == package.name { isInstallingRow() } else if isHovering { isHoveringRow() @@ -145,7 +155,7 @@ struct LanguageServerRowView: View, Equatable { "", isOn: Binding( get: { isEnabled }, - set: { registryManager.setPackageEnabled(packageName: package.name, enabled: $0) } + set: { registryManager?.setPackageEnabled(packageName: package.name, enabled: $0) } ) ) .toggleStyle(.switch) @@ -195,7 +205,7 @@ struct LanguageServerRowView: View, Equatable { } label: { Text("Install") } - .disabled(registryManager.isInstalling) + .disabled(registryState.isInstalling) } @ViewBuilder @@ -220,7 +230,7 @@ struct LanguageServerRowView: View, Equatable { isRemoving = true Task { do { - try await registryManager.removeLanguageServer(packageName: package.name) + try await registryManager?.removeLanguageServer(packageName: package.name) await MainActor.run { isRemoving = false } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServersView.swift similarity index 83% rename from CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServersView.swift index 44cd02aafb..df41babfd8 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/LanguageServersView.swift @@ -1,16 +1,19 @@ // -// ExtensionsSettingsView.swift +// LanguageServersView.swift // CodeEdit // // Created by Abe Malla on 2/2/25. // +import CELSP import SwiftUI +import CodeEditCore /// Displays a searchable list of packages from the ``RegistryManager``. struct LanguageServersView: View { - @StateObject var registryManager: RegistryManager = .shared - @StateObject private var searchModel = FuzzySearchUIModel() + let registryManager: any RegistryManaging + @ObservedObject var registryState: RegistryViewState + @StateObject private var searchModel = FuzzyMatchUIModel() @State private var searchText: String = "" @State private var selectedInstall: PackageManagerInstallOperation? @@ -19,7 +22,7 @@ struct LanguageServersView: View { var body: some View { Group { SettingsForm { - if registryManager.isDownloadingRegistry { + if registryState.isDownloadingRegistry { HStack { Spacer() ProgressView() @@ -29,7 +32,7 @@ struct LanguageServersView: View { } Section { - List(searchModel.items ?? registryManager.registryItems, id: \.name) { item in + List(searchModel.items ?? registryState.registryItems, id: \.name) { item in LanguageServerRowView( package: item, onCancel: { @@ -48,7 +51,7 @@ struct LanguageServersView: View { } .searchable(text: $searchText) .onChange(of: searchText) { _, newValue in - searchModel.searchTextUpdated(searchText: newValue, allItems: registryManager.registryItems) + searchModel.searchTextUpdated(searchText: newValue, allItems: registryState.registryItems) } } header: { Label( @@ -61,7 +64,10 @@ struct LanguageServersView: View { LanguageServerInstallView(operation: operation) } } - .environmentObject(registryManager) + .environmentObject(registryState) + .task { + registryManager.loadRegistryIfNeeded() + } } private func getInfoString() -> AttributedString { diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/RegistryItem+FuzzyMatchable.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/RegistryItem+FuzzyMatchable.swift new file mode 100644 index 0000000000..71f17bbd33 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ExtensionsSettings/RegistryItem+FuzzyMatchable.swift @@ -0,0 +1,13 @@ +// +// RegistryItem+FuzzyMatchable.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +import CELSP +import CodeEditCore + +extension RegistryItem: FuzzyMatchable { + public var searchableString: String { name } +} diff --git a/CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/GeneralSettings/GeneralSettingsView.swift similarity index 85% rename from CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/GeneralSettings/GeneralSettingsView.swift index 0dbc5cc6e7..345b32e4be 100644 --- a/CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/GeneralSettings/GeneralSettingsView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings /// A view that implements the `General` settings page struct GeneralSettingsView: View { @@ -79,12 +80,12 @@ private extension GeneralSettingsView { var appearance: some View { Picker("Appearance", selection: $settings.appAppearance) { Text("System") - .tag(SettingsData.Appearances.system) + .tag(GeneralSettings.Appearances.system) Divider() Text("Light") - .tag(SettingsData.Appearances.light) + .tag(GeneralSettings.Appearances.light) Text("Dark") - .tag(SettingsData.Appearances.dark) + .tag(GeneralSettings.Appearances.dark) } .onChange(of: settings.appAppearance) { _, tag in tag.applyAppearance() @@ -95,9 +96,9 @@ private extension GeneralSettingsView { var showIssues: some View { Picker("Show Issues", selection: $settings.showIssues) { Text("Show Inline") - .tag(SettingsData.Issues.inline) + .tag(GeneralSettings.Issues.inline) Text("Show Minimized") - .tag(SettingsData.Issues.minimized) + .tag(GeneralSettings.Issues.minimized) } } @@ -117,14 +118,14 @@ private extension GeneralSettingsView { Group { Picker("File Extensions", selection: $settings.fileExtensionsVisibility) { Text("Hide all") - .tag(SettingsData.FileExtensionsVisibility.hideAll) + .tag(GeneralSettings.FileExtensionsVisibility.hideAll) Text("Show all") - .tag(SettingsData.FileExtensionsVisibility.showAll) + .tag(GeneralSettings.FileExtensionsVisibility.showAll) Divider() Text("Show only") - .tag(SettingsData.FileExtensionsVisibility.showOnly) + .tag(GeneralSettings.FileExtensionsVisibility.showOnly) Text("Hide only") - .tag(SettingsData.FileExtensionsVisibility.hideOnly) + .tag(GeneralSettings.FileExtensionsVisibility.hideOnly) } if case .showOnly = settings.fileExtensionsVisibility { TextField("", text: $settings.shownFileExtensions.string, axis: .vertical) @@ -142,9 +143,9 @@ private extension GeneralSettingsView { var fileIconStyle: some View { Picker("File Icon Style", selection: $settings.fileIconStyle) { Text("Color") - .tag(SettingsData.FileIconStyle.color) + .tag(GeneralSettings.FileIconStyle.color) Text("Monochrome") - .tag(SettingsData.FileIconStyle.monochrome) + .tag(GeneralSettings.FileIconStyle.monochrome) } .pickerStyle(.radioGroup) } @@ -152,9 +153,9 @@ private extension GeneralSettingsView { var navigatorTabBarPosition: some View { Picker("Navigator Tab Bar Position", selection: $settings.navigatorTabBarPosition) { Text("Top") - .tag(SettingsData.SidebarTabBarPosition.top) + .tag(GeneralSettings.SidebarTabBarPosition.top) Text("Side") - .tag(SettingsData.SidebarTabBarPosition.side) + .tag(GeneralSettings.SidebarTabBarPosition.side) } .pickerStyle(.radioGroup) } @@ -162,9 +163,9 @@ private extension GeneralSettingsView { var inspectorTabBarPosition: some View { Picker("Inspector Tab Bar Position", selection: $settings.inspectorTabBarPosition) { Text("Top") - .tag(SettingsData.SidebarTabBarPosition.top) + .tag(GeneralSettings.SidebarTabBarPosition.top) Text("Side") - .tag(SettingsData.SidebarTabBarPosition.side) + .tag(GeneralSettings.SidebarTabBarPosition.side) } .pickerStyle(.radioGroup) } @@ -172,12 +173,12 @@ private extension GeneralSettingsView { var reopenBehavior: some View { Picker("Reopen Behavior", selection: $settings.reopenBehavior) { Text("Welcome Screen") - .tag(SettingsData.ReopenBehavior.welcome) + .tag(GeneralSettings.ReopenBehavior.welcome) Divider() Text("Open Panel") - .tag(SettingsData.ReopenBehavior.openPanel) + .tag(GeneralSettings.ReopenBehavior.openPanel) Text("New Document") - .tag(SettingsData.ReopenBehavior.newDocument) + .tag(GeneralSettings.ReopenBehavior.newDocument) } } @@ -187,29 +188,29 @@ private extension GeneralSettingsView { selection: $settings.reopenWindowAfterClose ) { Text("Do nothing") - .tag(SettingsData.ReopenWindowBehavior.doNothing) + .tag(GeneralSettings.ReopenWindowBehavior.doNothing) Divider() Text("Show Welcome Window") - .tag(SettingsData.ReopenWindowBehavior.showWelcomeWindow) + .tag(GeneralSettings.ReopenWindowBehavior.showWelcomeWindow) Text("Quit") - .tag(SettingsData.ReopenWindowBehavior.quit) + .tag(GeneralSettings.ReopenWindowBehavior.quit) } } var projectNavigatorSize: some View { Picker("Project Navigator Size", selection: $settings.projectNavigatorSize) { Text("Small") - .tag(SettingsData.ProjectNavigatorSize.small) + .tag(GeneralSettings.ProjectNavigatorSize.small) Text("Medium") - .tag(SettingsData.ProjectNavigatorSize.medium) + .tag(GeneralSettings.ProjectNavigatorSize.medium) Text("Large") - .tag(SettingsData.ProjectNavigatorSize.large) + .tag(GeneralSettings.ProjectNavigatorSize.large) } } var findNavigatorDetail: some View { Picker("Find Navigator Detail", selection: $settings.findNavigatorDetail) { - ForEach(SettingsData.NavigatorDetail.allCases, id: \.self) { tag in + ForEach(GeneralSettings.NavigatorDetail.allCases, id: \.self) { tag in Text(tag.label).tag(tag) } } @@ -218,7 +219,7 @@ private extension GeneralSettingsView { // TODO: Implement reflecting Issue Navigator Detail preference and remove disabled modifier var issueNavigatorDetail: some View { Picker("Issue Navigator Detail", selection: $settings.issueNavigatorDetail) { - ForEach(SettingsData.NavigatorDetail.allCases, id: \.self) { tag in + ForEach(GeneralSettings.NavigatorDetail.allCases, id: \.self) { tag in Text(tag.label).tag(tag) } } diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/LocationsSettings/LocationsSettings.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/LocationsSettings/LocationsSettings.swift new file mode 100644 index 0000000000..283427b8c7 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/LocationsSettings/LocationsSettings.swift @@ -0,0 +1,26 @@ +// +// LocationsSettings.swift +// CodeEdit +// +// Created by Raymond Vleeshouwer on 24/06/23. +// + +import Foundation +import CodeEditSettings + +/// The Locations settings page. +/// +/// Not a `SettingsSection`: it persists nothing, it only lists where things already live. It used to +/// be nested inside `SettingsData`, which read as if it were one of the stored sections. +struct LocationsSettings: SearchableSettingsPage { + + /// The search keys + var searchKeys: [String] { + [ + "Settings Location", + "Themes Location", + "Extensions Location" + ] + .map { NSLocalizedString($0, comment: "") } + } +} diff --git a/CodeEdit/Features/Settings/Pages/LocationsSettings/LocationsSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/LocationsSettings/LocationsSettingsView.swift similarity index 90% rename from CodeEdit/Features/Settings/Pages/LocationsSettings/LocationsSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/LocationsSettings/LocationsSettingsView.swift index d797cc1f6b..3b3a5ecc5d 100644 --- a/CodeEdit/Features/Settings/Pages/LocationsSettings/LocationsSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/LocationsSettings/LocationsSettingsView.swift @@ -1,11 +1,12 @@ // -// LocationSettingsView.swift +// LocationsSettingsView.swift // CodeEdit // // Created by Raymond Vleeshouwer on 02/04/23. // import SwiftUI +import CodeEditSettings /// A view that implements the `Locations` settings section struct LocationsSettingsView: View { @@ -23,9 +24,9 @@ struct LocationsSettingsView: View { private extension LocationsSettingsView { @ViewBuilder private var applicationSupportLocation: some View { - ExternalLink(destination: Settings.shared.baseURL) { + ExternalLink(destination: SettingsLocation.baseURL) { Text("Application Support") - Text(Settings.shared.baseURL.path) + Text(SettingsLocation.baseURL.path) .font(.footnote) .foregroundColor(.secondary) } diff --git a/CodeEdit/Features/Settings/Pages/NavigationSettings/NavigationSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/NavigationSettings/NavigationSettingsView.swift similarity index 78% rename from CodeEdit/Features/Settings/Pages/NavigationSettings/NavigationSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/NavigationSettings/NavigationSettingsView.swift index 552eb4a075..b78f59ca90 100644 --- a/CodeEdit/Features/Settings/Pages/NavigationSettings/NavigationSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/NavigationSettings/NavigationSettingsView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings struct NavigationSettingsView: View { @AppSettings(\.navigation) @@ -24,9 +25,9 @@ private extension NavigationSettingsView { private var navigationStyle: some View { Picker("Navigation Style", selection: $settings.navigationStyle) { Text("Open in Tabs") - .tag(SettingsData.NavigationStyle.openInTabs) + .tag(NavigationSettings.NavigationStyle.openInTabs) Text("Open in Place") - .tag(SettingsData.NavigationStyle.openInPlace) + .tag(NavigationSettings.NavigationStyle.openInPlace) } } } diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsIgnoreGlobPatternItemView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsIgnoreGlobPatternItemView.swift similarity index 100% rename from CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsIgnoreGlobPatternItemView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsIgnoreGlobPatternItemView.swift diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsModel.swift similarity index 63% rename from CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsModel.swift index 28cf47818e..3e10fec63e 100644 --- a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsModel.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings /// The Search Settings View Model. Accessible via the singleton "``SearchSettings/shared``". /// @@ -15,11 +16,22 @@ import SwiftUI /// private var searchSettigs: SearchSettingsModel = .shared /// ``` final class SearchSettingsModel: ObservableObject { - /// Reads settings file for Search Settings and updates the values in this model - /// correspondingly - private init() { - let value = Settings[\.search].ignoreGlobPatterns - self.ignoreGlobPatterns = value + private init() {} + + /// The settings store. Property-injected by `AppDelegate` at launch — see `ThemeModel`'s + /// `settingsAccessor` for why these pre-existing singletons take their store this way. + private var settingsAccessor: SettingsAccessing = DefaultSettingsReader() + + /// Suppresses the write-back that `ignoreGlobPatterns`' `didSet` would otherwise perform while + /// seeding it *from* the store, which would save the value that was just read. + private var isSeeding = false + + /// Installs the store and seeds this model from it. Called once, by the composition root. + func configure(settings: SettingsAccessing) { + settingsAccessor = settings + isSeeding = true + ignoreGlobPatterns = settings.value(SearchSettings.self).ignoreGlobPatterns + isSeeding = false } static let shared: SearchSettingsModel = .init() @@ -52,10 +64,13 @@ final class SearchSettingsModel: ObservableObject { /// Stores the new values from the Search Settings Model into the settings.json whenever /// `ignoreGlobPatterns` is updated - @Published var ignoreGlobPatterns: [GlobPattern] { + @Published var ignoreGlobPatterns: [GlobPattern] = [] { didSet { + guard !isSeeding else { return } DispatchQueue.main.async { - Settings[\.search].ignoreGlobPatterns = self.ignoreGlobPatterns + var section = self.settingsAccessor.value(SearchSettings.self) + section.ignoreGlobPatterns = self.ignoreGlobPatterns + self.settingsAccessor.setValue(section) } } } diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsView.swift similarity index 100% rename from CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SearchSettings/SearchSettingsView.swift diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/IgnorePatternModel.swift similarity index 97% rename from CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/IgnorePatternModel.swift index 01c8d7aaba..9ebbbf84a2 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/IgnorePatternModel.swift @@ -5,7 +5,11 @@ // Created by Austin Condiff on 11/1/24. // +import CESourceControl +import CodeEditCore import Foundation +import ShellClient +import CodeEditSettings /// A model to manage Git ignore patterns for a file, including loading, saving, and monitoring changes. @MainActor @@ -28,7 +32,7 @@ class IgnorePatternModel: ObservableObject { @Published var selection: Set = [] /// A client for interacting with the Git configuration. - private let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + private let gitConfig: GitConfigClient /// A file system monitor for detecting changes to the Git ignore file. private var fileMonitor: DispatchSourceFileSystemObject? @@ -36,7 +40,8 @@ class IgnorePatternModel: ObservableObject { /// Task tracking the current save operation private var savingTask: Task? - init() { + init(shellClient: ShellClientProtocol = ShellClient()) { + self.gitConfig = GitConfigClient(shellClient: shellClient) Task { try? await startFileMonitor() await loadPatterns() diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift similarity index 100% rename from CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift diff --git a/CodeEdit/Utils/Limiter.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/Limiter.swift similarity index 100% rename from CodeEdit/Utils/Limiter.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/Limiter.swift diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift similarity index 81% rename from CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index 652094d7e4..a52f9a6626 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -5,13 +5,20 @@ // Created by Raymond Vleeshouwer on 02/04/23. // +import CESourceControl +import CodeEditCore import SwiftUI +import ShellClient +import CodeEditSettings struct SourceControlGeneralView: View { @AppSettings(\.sourceControl.general) var settings - let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + @Environment(\.shellClient) + private var shellClient + + private var gitConfig: GitConfigClient { GitConfigClient(shellClient: shellClient ?? ShellClient()) } var body: some View { Group { @@ -83,9 +90,9 @@ private extension SourceControlGeneralView { selection: $settings.revisionComparisonLayout ) { Text("Local Revision on Left Side") - .tag(SettingsData.RevisionComparisonLayout.localLeft) + .tag(SourceControlSettings.RevisionComparisonLayout.localLeft) Text("Local Revision on Right Side") - .tag(SettingsData.RevisionComparisonLayout.localRight) + .tag(SourceControlSettings.RevisionComparisonLayout.localRight) } } @@ -95,9 +102,9 @@ private extension SourceControlGeneralView { selection: $settings.controlNavigatorOrder ) { Text("Sort by Name") - .tag(SettingsData.ControlNavigatorOrder.sortByName) + .tag(SourceControlSettings.ControlNavigatorOrder.sortByName) Text("Sort by Date") - .tag(SettingsData.ControlNavigatorOrder.sortByDate) + .tag(SourceControlSettings.ControlNavigatorOrder.sortByDate) } } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlGitView.swift similarity index 94% rename from CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index 7c9e9e70b7..4be4f10ae0 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -5,13 +5,22 @@ // Created by Raymond Vleeshouwer on 02/04/23. // +import CESourceControl +import CodeEditCore import SwiftUI +import ShellClient +import CodeEditSettings struct SourceControlGitView: View { @AppSettings(\.sourceControl.git) var git - let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + @Environment(\.shellClient) + private var shellClient + @Environment(\.workspaceWindowManager) + private var windowManager + + private var gitConfig: GitConfigClient { GitConfigClient(shellClient: shellClient ?? ShellClient()) } @State private var authorName: String = "" @State private var authorEmail: String = "" @@ -202,14 +211,7 @@ private extension SourceControlGitView { FileManager.default.createFile(atPath: fileURL.path, contents: nil) } - NSDocumentController.shared.openDocument( - withContentsOf: fileURL, - display: true - ) { _, _, error in - if let error = error { - print("Failed to open document: \(error.localizedDescription)") - } - } + windowManager?.openDocument(at: fileURL, onCompletion: {}) } private func openGitIgnoreFile() { @@ -223,7 +225,7 @@ private extension SourceControlGitView { } // Open the file in the editor - try await NSDocumentController.shared.openDocument(withContentsOf: fileURL, display: true) + windowManager?.openDocument(at: fileURL, onCompletion: {}) } catch { print("Failed to open document: \(error.localizedDescription)") } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift similarity index 97% rename from CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift index 14ee02523f..a6b6a465e1 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditSettings +import CodeEditUI struct SourceControlSettingsView: View { @AppSettings(\.sourceControl.general) diff --git a/CodeEdit/Features/Settings/Pages/TerminalSettings/TerminalSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/TerminalSettings/TerminalSettingsView.swift similarity index 90% rename from CodeEdit/Features/Settings/Pages/TerminalSettings/TerminalSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/TerminalSettings/TerminalSettingsView.swift index 99c90fdd74..d496d8eebb 100644 --- a/CodeEdit/Features/Settings/Pages/TerminalSettings/TerminalSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/TerminalSettings/TerminalSettingsView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CETerminal +import CodeEditSettings struct TerminalSettingsView: View { @AppSettings(\.terminal) @@ -41,23 +43,23 @@ private extension TerminalSettingsView { @ViewBuilder private var shellSelector: some View { Picker("Shell", selection: $settings.shell) { Text("System Default") - .tag(SettingsData.TerminalShell.system) + .tag(TerminalSettings.Shell.system) Divider() Text("Zsh") - .tag(SettingsData.TerminalShell.zsh) + .tag(TerminalSettings.Shell.zsh) Text("Bash") - .tag(SettingsData.TerminalShell.bash) + .tag(TerminalSettings.Shell.bash) } } private var cursorStyle: some View { Picker("Terminal Cursor Style", selection: $settings.cursorStyle) { Text("Block") - .tag(SettingsData.TerminalCursorStyle.block) + .tag(TerminalSettings.CursorStyle.block) Text("Underline") - .tag(SettingsData.TerminalCursorStyle.underline) + .tag(TerminalSettings.CursorStyle.underline) Text("Bar") - .tag(SettingsData.TerminalCursorStyle.bar) + .tag(TerminalSettings.CursorStyle.bar) } } diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift similarity index 98% rename from CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift index d7c885d13e..8dcdda3c95 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift @@ -6,9 +6,10 @@ // import SwiftUI +import CodeEditSettings struct InvisiblesSettingsView: View { - typealias Config = SettingsData.TextEditingSettings.InvisibleCharactersConfig + typealias Config = TextEditingSettings.InvisibleCharactersConfig @Binding var invisibleCharacters: Config diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift similarity index 90% rename from CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift index 73d9eca772..aa86635208 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings /// A view that implements the `Text Editing` settings page struct TextEditingSettingsView: View { @@ -101,19 +102,19 @@ private extension TextEditingSettingsView { selection: $textEditing.overscroll ) { Text("None") - .tag(SettingsData.TextEditingSettings.OverscrollOption.none) + .tag(TextEditingSettings.OverscrollOption.none) Divider() Text("Small") .tag( - SettingsData.TextEditingSettings.OverscrollOption.small + TextEditingSettings.OverscrollOption.small ) Text("Medium") .tag( - SettingsData.TextEditingSettings.OverscrollOption.medium + TextEditingSettings.OverscrollOption.medium ) Text("Large") .tag( - SettingsData.TextEditingSettings.OverscrollOption.large + TextEditingSettings.OverscrollOption.large ) } } @@ -133,9 +134,9 @@ private extension TextEditingSettingsView { Group { Picker("Prefer Indent Using", selection: $textEditing.indentOption.indentType) { Text("Tabs") - .tag(SettingsData.TextEditingSettings.IndentOption.IndentType.tab) + .tag(TextEditingSettings.IndentOption.IndentType.tab) Text("Spaces") - .tag(SettingsData.TextEditingSettings.IndentOption.IndentType.spaces) + .tag(TextEditingSettings.IndentOption.IndentType.spaces) } if textEditing.indentOption.indentType == .spaces { HStack { @@ -191,11 +192,11 @@ private extension TextEditingSettingsView { "Bracket Pair Highlight", selection: $textEditing.bracketEmphasis.highlightType ) { - Text("Disabled").tag(SettingsData.TextEditingSettings.BracketPairEmphasis.HighlightType.disabled) + Text("Disabled").tag(TextEditingSettings.BracketPairEmphasis.HighlightType.disabled) Divider() - Text("Bordered").tag(SettingsData.TextEditingSettings.BracketPairEmphasis.HighlightType.bordered) - Text("Flash").tag(SettingsData.TextEditingSettings.BracketPairEmphasis.HighlightType.flash) - Text("Underline").tag(SettingsData.TextEditingSettings.BracketPairEmphasis.HighlightType.underline) + Text("Bordered").tag(TextEditingSettings.BracketPairEmphasis.HighlightType.bordered) + Text("Flash").tag(TextEditingSettings.BracketPairEmphasis.HighlightType.flash) + Text("Underline").tag(TextEditingSettings.BracketPairEmphasis.HighlightType.underline) } if [.bordered, .underline].contains(textEditing.bracketEmphasis.highlightType) { Toggle("Use Custom Color", isOn: $textEditing.bracketEmphasis.useCustomColor) diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/Theme+FuzzyMatchable.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/Theme+FuzzyMatchable.swift new file mode 100644 index 0000000000..501081147b --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/Theme+FuzzyMatchable.swift @@ -0,0 +1,16 @@ +// +// Theme+FuzzyMatchable.swift +// CodeEdit +// +// Created by Tommy Ludwig on 14.08.24. +// + +import Foundation +import CodeEditSettings +import CodeEditCore + +extension Theme: FuzzyMatchable { + public var searchableString: String { + return id + } +} diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/Theme+SwiftColor.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/Theme+SwiftColor.swift new file mode 100644 index 0000000000..c2f160104f --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/Theme+SwiftColor.swift @@ -0,0 +1,27 @@ +// +// Theme+SwiftColor.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/08/2026. +// + +import CodeEditCore +import CodeEditUI +import SwiftUI + +extension Theme.Attributes { + /// The attribute's color as a SwiftUI `Color`; setting it stores the new value as a hex string. + /// + /// Deliberately app-target-private: `Theme` lives in `CodeEditCore`, which may not import SwiftUI, + /// and `CodeEditUI` — which owns the hex conversion — may not import `CodeEditCore`. Each module + /// that needs a typed color keeps its own adapter over the shared `String`-keyed helper. It stays + /// settable because the theme settings detail view binds `$…swiftColor` straight into pickers. + var swiftColor: Color { + get { + Color(hex: color) + } + set { + self.color = newValue.hexString + } + } +} diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel+CRUD.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel+CRUD.swift new file mode 100644 index 0000000000..ca8d1c4894 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel+CRUD.swift @@ -0,0 +1,126 @@ +// +// ThemeModel+CRUD.swift +// CodeEdit +// +// Created by Austin Condiff on 6/18/24. +// + +import SwiftUI +import CodeEditCore +import CodeEditSettings +import UniformTypeIdentifiers + +extension ThemeModel { + /// Loads all available themes from disk, applies overrides, and selects the initial theme. + func loadThemes() throws { + themes.removeAll() + + let prefs = settingsAccessor.value(ThemeSettings.self) + themes = try repository.loadAllThemes(overrides: prefs.overrides) + + // Select initial themes based on preferences + self.selectedDarkTheme = self.darkThemes.first { + $0.name == prefs.selectedDarkTheme + } ?? self.darkThemes.first + + self.selectedLightTheme = self.lightThemes.first { + $0.name == prefs.selectedLightTheme + } ?? self.lightThemes.first + + let userSelectedTheme = self.themes.first { $0.name == prefs.selectedTheme } + let systemAppearance = NSAppearance.currentDrawing().name + + if userSelectedTheme != nil { + self.selectedTheme = userSelectedTheme + } else { + if systemAppearance == .darkAqua { + self.selectedTheme = self.selectedDarkTheme + } else { + self.selectedTheme = self.selectedLightTheme + } + } + } + + func importTheme() { + let openPanel = NSOpenPanel() + let allowedTypes = [UTType(filenameExtension: "cetheme")!] + + openPanel.prompt = "Import" + openPanel.allowedContentTypes = allowedTypes + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + + openPanel.begin { result in + if result.rawValue == NSApplication.ModalResponse.OK.rawValue { + if let url = openPanel.urls.first { + self.duplicate(url) + } + } + } + } + + func duplicate(_ url: URL) { + do { + self.isAdding = true + + let isBundledURL = bundledThemesURL?.absoluteString ?? "" + let isImporting = + !url.absoluteString.hasPrefix(isBundledURL) + && !url.absoluteString.hasPrefix(themesURL.absoluteString) + + let (destinationURL, newFileName) = try repository.duplicateFile(from: url) + + try self.loadThemes() + + if let index = self.themes.firstIndex(where: { $0.fileURL == destinationURL }) { + self.themes[index].displayName = newFileName + self.themes[index].name = newFileName.lowercased().replacingOccurrences(of: " ", with: "-") + + if isImporting != true { + self.themes[index].author = NSFullUserName() + self.save(self.themes[index]) + } + + self.previousTheme = self.selectedTheme + + activateTheme(self.themes[index]) + + self.detailsTheme = self.themes[index] + self.detailsIsPresented = true + } + } catch { + print("Error adding theme: \(error.localizedDescription)") + } + } + + func rename(to newName: String, theme: Theme) { + do { + let existingNames = themes.filter { $0 != theme }.map(\.displayName) + _ = try repository.rename(theme, to: newName, existingNames: existingNames) + try self.loadThemes() + } catch { + print("Error renaming theme: \(error.localizedDescription)") + } + } + + /// Save theme to file + func save(_ theme: Theme) { + do { + try repository.save(theme) + } catch { + print("Error saving theme: \(error.localizedDescription)") + } + } + + /// Removes the given theme from disk and reloads. + func delete(_ theme: Theme) { + do { + try repository.delete(theme) + updateThemeSettings { $0.overrides.removeValue(forKey: theme.name) } + try self.loadThemes() + } catch { + print(error) + } + } +} diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel+Export.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel+Export.swift new file mode 100644 index 0000000000..b3d44d0b6b --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel+Export.swift @@ -0,0 +1,65 @@ +// +// ThemeModel+Export.swift +// CodeEdit +// +// Created by Austin Condiff on 6/18/24. +// + +import SwiftUI +import CodeEditCore +import CodeEditSettings +import UniformTypeIdentifiers + +/// Export dialog methods for themes. +extension ThemeModel { + func exportTheme(_ theme: Theme) { + guard let themeFileURL = theme.fileURL else { + print("Theme file URL not found.") + return + } + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [UTType(filenameExtension: "cetheme")!] + savePanel.nameFieldStringValue = theme.displayName + savePanel.prompt = "Export" + savePanel.canCreateDirectories = true + + savePanel.begin { response in + if response == .OK, let destinationURL = savePanel.url { + do { + try FileManager.default.copyItem(at: themeFileURL, to: destinationURL) + print("Theme exported successfully to \(destinationURL.path)") + } catch { + print("Failed to export theme: \(error.localizedDescription)") + } + } + } + } + + func exportAllCustomThemes() { + let openPanel = NSOpenPanel() + openPanel.prompt = "Export" + openPanel.canChooseFiles = false + openPanel.canChooseDirectories = true + openPanel.allowsMultipleSelection = false + + openPanel.begin { result in + if result == .OK, let exportDirectory = openPanel.url { + let customThemes = self.themes.filter { !$0.isBundled } + + for theme in customThemes { + guard let sourceURL = theme.fileURL else { continue } + + let destinationURL = exportDirectory.appending(path: "\(theme.displayName).cetheme") + + do { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + print("Exported \(theme.displayName) to \(destinationURL.path)") + } catch { + print("Failed to export \(theme.displayName): \(error.localizedDescription)") + } + } + } + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel.swift similarity index 55% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel.swift index fb755a9fea..95c0fce2e3 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeModel.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings import UniformTypeIdentifiers /// The Theme View Model. Accessible via the singleton "``ThemeModel/shared``". @@ -18,12 +20,31 @@ import UniformTypeIdentifiers final class ThemeModel: ObservableObject { static let shared: ThemeModel = .init() - @AppSettings(\.theme) - var settings + /// The settings store, installed by `configure(settings:)`. `ThemeModel` is a pre-existing + /// singleton that this change does not dismantle, so it cannot take the store through `init`. + /// Left at `DefaultSettingsReader` it traps in debug, which is the point — a missing install is + /// a wiring bug, not a runtime condition. + private(set) var settingsAccessor: SettingsAccessing = DefaultSettingsReader() + + /// Read-modify-write of the whole `ThemeSettings` section, the granularity the store works in. + func updateThemeSettings(_ mutate: (inout ThemeSettings) -> Void) { + var section = settingsAccessor.value(ThemeSettings.self) + mutate(§ion) + settingsAccessor.setValue(section) + } + + /// The themes in effect, handed to the editor and the terminal as an `@EnvironmentObject`. + /// + /// Kept in sync from the `didSet` observers on ``selectedTheme`` and ``selectedDarkTheme``. + /// Theme *management* state stays here; only the two themes in effect cross into rendering. + let activeTheme = ActiveTheme() /// Default instance of the `FileManager` let filemanager = FileManager.default + /// Handles all theme file I/O operations. + let repository: ThemeRepository + /// The base folder url `~/Library/Application Support/CodeEdit/` private var baseURL: URL { filemanager.homeDirectoryForCurrentUser.appending(path: "Library/Application Support/CodeEdit") @@ -56,8 +77,7 @@ final class ThemeModel: ObservableObject { @Published var selectedLightTheme: Theme? { didSet { DispatchQueue.main.async { - Settings.shared - .preferences.theme.selectedLightTheme = self.selectedLightTheme?.name ?? "Broken" + self.updateThemeSettings { $0.selectedLightTheme = self.selectedLightTheme?.name ?? "Broken" } } } } @@ -66,9 +86,11 @@ final class ThemeModel: ObservableObject { /// Used for auto-switching theme to match macOS system appearance @Published var selectedDarkTheme: Theme? { didSet { + // Synchronous on purpose: the async hop below defers a *settings write*. Deferring the + // in-memory update too would delay the re-render this holder exists to deliver. + publishActiveTheme() DispatchQueue.main.async { - Settings.shared - .preferences.theme.selectedDarkTheme = self.selectedDarkTheme?.name ?? "Broken" + self.updateThemeSettings { $0.selectedDarkTheme = self.selectedDarkTheme?.name ?? "Broken" } } } } @@ -85,12 +107,27 @@ final class ThemeModel: ObservableObject { /// The currently selected ``Theme``. @Published var selectedTheme: Theme? { didSet { + // Synchronous on purpose — see ``selectedDarkTheme``. + publishActiveTheme() DispatchQueue.main.async { - Settings[\.theme].selectedTheme = self.selectedTheme?.name + self.updateThemeSettings { $0.selectedTheme = self.selectedTheme?.name } } } } + /// Pushes the current selection into ``activeTheme``, which assigns and publishes unconditionally. + /// + /// Unconditional on purpose: ``Theme`` equality is by name, so any `!=` guard would drop colour + /// edits made to the theme that is already active. See ``ActiveTheme/update(current:dark:)``. + /// + /// The `?? themes.first` carries over the fallback both former injection sites applied: if no + /// theme matches the current appearance, any loaded theme beats none. It is deliberately not + /// `themes.first!` — ``ActiveTheme/current`` is already optional, so the write site has no + /// reason to trap. ``selectedDarkTheme`` gets no fallback because the old injection had none. + private func publishActiveTheme() { + activeTheme.update(current: selectedTheme ?? themes.first, dark: selectedDarkTheme) + } + @Published var previousTheme: Theme? /// Only themes where ``Theme/appearance`` == ``Theme/ThemeType/dark`` @@ -104,6 +141,20 @@ final class ThemeModel: ObservableObject { } private init() { + let base = filemanager.homeDirectoryForCurrentUser.appending(path: "Library/Application Support/CodeEdit") + self.repository = ThemeRepository( + themesURL: base.appending(path: "Themes", directoryHint: .isDirectory), + bundledThemesURL: Bundle.main.resourceURL?.appending(path: "DefaultThemes", directoryHint: .isDirectory) + ) + } + + /// Installs the settings store and loads the themes from disk. + /// + /// Loading is deliberately *not* in `init`: it reads `theme` out of the store, and `init` runs + /// the moment anything first touches `shared` — including the very line that would assign the + /// store. The load would then read `DefaultSettingsReader` and select the wrong theme. + func configure(settings: SettingsAccessing) { + settingsAccessor = settings do { try loadThemes() } catch { @@ -155,54 +206,4 @@ final class ThemeModel: ObservableObject { } } - func exportTheme(_ theme: Theme) { - guard let themeFileURL = theme.fileURL else { - print("Theme file URL not found.") - return - } - - let savePanel = NSSavePanel() - savePanel.allowedContentTypes = [UTType(filenameExtension: "cetheme")!] - savePanel.nameFieldStringValue = theme.displayName - savePanel.prompt = "Export" - savePanel.canCreateDirectories = true - - savePanel.begin { response in - if response == .OK, let destinationURL = savePanel.url { - do { - try FileManager.default.copyItem(at: themeFileURL, to: destinationURL) - print("Theme exported successfully to \(destinationURL.path)") - } catch { - print("Failed to export theme: \(error.localizedDescription)") - } - } - } - } - - func exportAllCustomThemes() { - let openPanel = NSOpenPanel() - openPanel.prompt = "Export" - openPanel.canChooseFiles = false - openPanel.canChooseDirectories = true - openPanel.allowsMultipleSelection = false - - openPanel.begin { result in - if result == .OK, let exportDirectory = openPanel.url { - let customThemes = self.themes.filter { !$0.isBundled } - - for theme in customThemes { - guard let sourceURL = theme.fileURL else { continue } - - let destinationURL = exportDirectory.appending(path: "\(theme.displayName).cetheme") - - do { - try FileManager.default.copyItem(at: sourceURL, to: destinationURL) - print("Exported \(theme.displayName) to \(destinationURL.path)") - } catch { - print("Failed to export \(theme.displayName): \(error.localizedDescription)") - } - } - } - } - } } diff --git a/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeRepository.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeRepository.swift new file mode 100644 index 0000000000..5a8eb1b857 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeRepository.swift @@ -0,0 +1,188 @@ +// +// ThemeRepository.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 07.04.26. +// + +import Foundation +import CodeEditCore +import CodeEditSettings + +/// Handles all file I/O operations for themes. +/// +/// This separates disk operations from state management (ThemeModel), +/// making file operations independently testable and the data flow clearer. +struct ThemeRepository { + let themesURL: URL + let bundledThemesURL: URL? + let fileManager: FileManager + + init( + themesURL: URL, + bundledThemesURL: URL?, + fileManager: FileManager = .default + ) { + self.themesURL = themesURL + self.bundledThemesURL = bundledThemesURL + self.fileManager = fileManager + } + + // MARK: - Loading + + /// Loads a single theme from a URL. + func loadTheme(from url: URL) throws -> Theme { + let json = try Data(contentsOf: url) + return try JSONDecoder().decode(Theme.self, from: json) + } + + /// Discovers and loads all themes from user and bundled directories. + /// Applies overrides from settings and marks bundled themes. + func loadAllThemes( + overrides: [String: [String: [String: Theme.Attributes]]] + ) throws -> [Theme] { + ensureThemesDirectoryExists() + + let userURLs = themeFileURLs(in: themesURL) + let bundledURLs = bundledThemesURL.map { themeFileURLs(in: $0) } ?? [] + let allURLs = userURLs + bundledURLs + + var themes: [Theme] = [] + for url in allURLs { + guard var theme = try? loadTheme(from: url) else { continue } + + applyOverrides(to: &theme, overrides: overrides) + theme.isBundled = bundledThemesURL.map { url.path.contains($0.path) } ?? false + theme.fileURL = url + themes.append(theme) + } + + return themes + } + + // MARK: - Saving + + /// Saves a theme to its file URL as pretty-printed JSON. + func save(_ theme: Theme) throws { + guard let fileURL = theme.fileURL else { return } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(theme) + let json = try JSONSerialization.jsonObject(with: data) + let prettyJSON = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) + try prettyJSON.write(to: fileURL, options: .atomic) + } + + // MARK: - Delete + + /// Removes a theme file from disk. + func delete(_ theme: Theme) throws { + guard let url = theme.fileURL else { return } + try fileManager.removeItem(at: url) + } + + // MARK: - Rename + + /// Moves a theme file to a new name, resolving conflicts. + /// Returns the new file URL. + func rename(_ theme: Theme, to newName: String, existingNames: [String]) throws -> URL { + guard let oldURL = theme.fileURL else { + throw NSError( + domain: "ThemeRepository", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Theme file URL not found"] + ) + } + + var finalName = newName + var finalURL = themesURL.appending(path: finalName).appendingPathExtension("cetheme") + var iterator = 1 + + while existingNames.contains(where: { $0 == finalName && finalName != theme.displayName }) { + finalName = "\(newName) \(iterator)" + finalURL = themesURL.appending(path: finalName).appendingPathExtension("cetheme") + iterator += 1 + } + + try fileManager.moveItem(at: oldURL, to: finalURL) + return finalURL + } + + // MARK: - Duplicate / Import + + /// Copies a theme file to the themes directory, resolving filename conflicts. + /// Returns the destination URL and resolved filename. + func duplicateFile(from sourceURL: URL) throws -> (url: URL, name: String) { + var destinationURL = themesURL.appending(path: sourceURL.lastPathComponent) + let fileExtension = destinationURL.pathExtension + var fileName = destinationURL.deletingPathExtension().lastPathComponent + var newFileName = fileName + var iterator = 1 + + let isBundled = bundledThemesURL.map { sourceURL.absoluteString.hasPrefix($0.absoluteString) } ?? false + + if isBundled { + newFileName = "\(fileName) \(iterator)" + destinationURL = themesURL + .appending(path: newFileName) + .appendingPathExtension(fileExtension) + } + + while fileManager.fileExists(atPath: destinationURL.path) { + fileName = destinationURL.deletingPathExtension().lastPathComponent + if let range = fileName.range(of: " \\d+$", options: .regularExpression) { + fileName = String(fileName[.. [URL] { + let filenames = (try? fileManager.contentsOfDirectory(atPath: directory.path)) ?? [] + return filenames + .filter { $0.hasSuffix(".cetheme") } + .map { directory.appending(path: $0) } + } + + private func applyOverrides( + to theme: inout Theme, + overrides: [String: [String: [String: Theme.Attributes]]] + ) { + guard let terminalColors = try? theme.terminal.allProperties() as? [String: Theme.Attributes], + let editorColors = try? theme.editor.allProperties() as? [String: Theme.Attributes] + else { return } + + if let terminalOverrides = overrides[theme.name]?["terminal"] { + for key in terminalColors.keys { + if let attributes = terminalOverrides[key] { + theme.terminal[key] = attributes + } + } + } + + if let editorOverrides = overrides[theme.name]?["editor"] { + for key in editorColors.keys { + if let attributes = editorOverrides[key] { + theme.editor[key] = attributes + } + } + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift similarity index 97% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift index 58f2403de5..d83da02d73 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingThemeRow.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings +import CodeEditUI struct ThemeSettingsThemeRow: View { @Binding var theme: Theme diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsColorPreview.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsColorPreview.swift similarity index 98% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsColorPreview.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsColorPreview.swift index 42e2f97526..6afea06be9 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsColorPreview.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsColorPreview.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings struct ThemeSettingsColorPreview: View { var theme: Theme diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift similarity index 99% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift index d6fc59657e..c79837bc38 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsThemeDetails.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings struct ThemeSettingsThemeDetails: View { @Environment(\.dismiss) diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift similarity index 99% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift index d02f2b912b..7c84ba3188 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsThemeToken.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct ThemeSettingsThemeToken: View { var label: String diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsView.swift similarity index 96% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsView.swift index 04c94db1b6..514b93205e 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/Pages/ThemeSettings/ThemeSettingsView.swift @@ -1,11 +1,14 @@ // -// ThemePreferencesView.swift +// ThemeSettingsView.swift // CodeEdit // // Created by Lukas Pistrol on 30.03.22. // import SwiftUI +import CodeEditCore +import CodeEditSettings +import CodeEditUI /// A view that implements the `Theme` preference section struct ThemeSettingsView: View { @@ -41,7 +44,7 @@ struct ThemeSettingsView: View { .disabled(themeModel.selectedTheme == nil) .help("Create a new Theme") - MenuWithButtonStyle(systemImage: "ellipsis", menu: { + ButtonStyledMenu(systemImage: "ellipsis", menu: { Group { Button { themeModel.importTheme() @@ -147,7 +150,7 @@ struct ThemeSettingsView: View { } private func filterAndSortThemes(_ themes: [Theme]) async -> [Theme] { - return await themes.fuzzySearch(query: themeSearchQuery).map { $1 } + return await themes.fuzzyMatches(query: themeSearchQuery).map { $1 } } } diff --git a/CodeEdit/Utils/Protocols/SearchableSettingsPage.swift b/CodeEdit/AuxiliaryWindows/Settings/Search/SearchableSettingsPage.swift similarity index 100% rename from CodeEdit/Utils/Protocols/SearchableSettingsPage.swift rename to CodeEdit/AuxiliaryWindows/Settings/Search/SearchableSettingsPage.swift diff --git a/CodeEdit/AuxiliaryWindows/Settings/Search/SettingsSearchKeys.swift b/CodeEdit/AuxiliaryWindows/Settings/Search/SettingsSearchKeys.swift new file mode 100644 index 0000000000..0d0b328d64 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/Search/SettingsSearchKeys.swift @@ -0,0 +1,240 @@ +// +// SettingsSearchKeys.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import CELSP +import CESourceControl +import CETerminal +import Foundation +import CodeEditSettings + +// App-side settings-search support. `searchKeys` are localized UI labels for the +// settings search feature (presentation, not preference data), so they live in the +// app rather than the CodeEditSettings package. One conformance extension per +// persisted settings page, plus `propertiesOf`. + +extension GeneralSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Appearance", + "File Icon Style", + "Tab Bar Style", + "Show Jump Bar", + "Dim editors without focus", + "Navigator Tab Bar Position", + "Inspector Tab Bar Position", + "Show Issues", + "Show Live Issues", + "Automatically save change to disk", + "Automatically reveal in project navigator", + "Reopen Behavior", + "After the last window is closed", + "File Extensions", + "Project Navigator Size", + "Find Navigator Detail", + "Issue Navigator Detail", + "Show “Open With CodeEdit“ option in Finder", + "'codeedit' Shell command", + "Dialog Warnings", + "Check for updates", + "Automatically check for app updates", + "Include pre-release versions" + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension AccountsSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Accounts", + "Delete Account...", + "Add Account..." + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension NavigationSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Navigation Style", + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension ThemeSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Automatically Change theme based on system appearance", + "Always use dark terminal appearance", + "Use theme background", + "Light Appearance", + "GitHub Light", + "Xcode Light", + "Solarized Light", + "Solarized Dark", + "Midnight", + "Xcode Dark", + "GitHub Dark" + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension TextEditingSettings: SearchableSettingsPage { + var searchKeys: [String] { + var keys = [ + "Prefer Indent Using", + "Tab Width", + "Wrap lines to editor width", + "Editor Overscroll", + "Font", + "Font Size", + "Font Weight", + "Line Height", + "Letter Spacing", + "Autocomplete braces", + "Enable type-over completion", + "Bracket Pair Emphasis", + "Bracket Pair Highlight", + "Show Gutter", + "Show Minimap", + "Reformat at Column", + "Show Reformatting Guide", + "Invisibles", + "Warning Characters" + ] + if #available(macOS 14.0, *) { + keys.append("System Cursor") + } + return keys.map { NSLocalizedString($0, comment: "") } + } +} + +extension TerminalSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Shell", + "Use \"Option\" key as \"Meta\"", + "Use text editor font", + "Font", + "Font Size", + "Terminal Cursor Style", + "Blink Cursor" + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension SourceControlSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "General", + "Enable source control", + "Refresh local status automatically", + "Fetch and refresh server status automatically", + "Add and remove files automatically", + "Select files to commit automatically", + "Show source control changes", + "Include upstream changes", + "Comparison view", + "Source control navigator", + "Default branch name", + "Git", + "Author Name", + "Author Email", + "Prefer to rebase when pulling", + "Show merge commits in per-file log" + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension SearchSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Ignore Glob Patterns", + "Ignore Patterns" + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension LanguageServerSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Language Servers", + "LSP Binaries", + "Linters", + "Formatters", + "Debug Protocol", + "DAP", + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension DeveloperSettings: SearchableSettingsPage { + var searchKeys: [String] { + [ + "Developer", + "Language Server Protocol", + "LSP Binaries", + "Show Internal Development Inspector" + ] + .map { NSLocalizedString($0, comment: "") } + } +} + +extension SettingsPage { + // swiftlint:disable cyclomatic_complexity + /// The searchable settings of one page. + /// + /// Reads no stored value — every `searchKeys` list is a constant of its section type — so this + /// is a static function on the page rather than a method on the settings aggregate, which is now + /// a façade that would need a store just to answer it. + static func propertiesOf(_ name: SettingsPage.Name) -> [SettingsPage] { + var settings: [SettingsPage] = [] + + switch name { + case .general: + GeneralSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .accounts: + AccountsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .navigation: + NavigationSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .theme: + ThemeSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .textEditing: + TextEditingSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .terminal: + TerminalSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .search: + SearchSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .sourceControl: + SourceControlSettings().searchKeys.forEach { + settings.append(.init(name, isSetting: true, settingName: $0)) + } + case .location: + LocationsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .languageServers: + LanguageServerSettings().searchKeys.forEach { + settings.append(.init(name, isSetting: true, settingName: $0)) + } + case .developer: + DeveloperSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .behavior: return [.init(name, settingName: "Error")] + case .components: return [.init(name, settingName: "Error")] + case .keybindings: return [.init(name, settingName: "Error")] + case .advanced: return [.init(name, settingName: "Error")] + } + + return settings + } + // swiftlint:enable cyclomatic_complexity +} diff --git a/CodeEdit/Features/Settings/Models/SettingsSearchResult.swift b/CodeEdit/AuxiliaryWindows/Settings/Search/SettingsSearchResult.swift similarity index 100% rename from CodeEdit/Features/Settings/Models/SettingsSearchResult.swift rename to CodeEdit/AuxiliaryWindows/Settings/Search/SettingsSearchResult.swift diff --git a/CodeEdit/Utils/Extensions/String/String+HighlightOccurrences.swift b/CodeEdit/AuxiliaryWindows/Settings/Search/String+HighlightOccurrences.swift similarity index 100% rename from CodeEdit/Utils/Extensions/String/String+HighlightOccurrences.swift rename to CodeEdit/AuxiliaryWindows/Settings/Search/String+HighlightOccurrences.swift diff --git a/CodeEdit/AuxiliaryWindows/Settings/SettingsData.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsData.swift new file mode 100644 index 0000000000..1f2810c717 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/SettingsData.swift @@ -0,0 +1,99 @@ +// +// SettingsData.swift +// CodeEdit +// +// Created by Lukas Pistrol on 01.04.22. +// + +import CELSP +import CESourceControl +import CETerminal +import CodeEditSettings + +/// # SettingsData +/// +/// A **façade** over a ``SettingsAccessing``, presenting the app's eleven settings sections as one +/// aggregate so `AppSettings` key paths like `\.theme.matchAppearance` keep working. +/// +/// It is no longer `Codable` and holds no state: the store owns persistence, one section at a time, +/// and every property here forwards to it. That is what lets a section be moved into its owning +/// feature package without the aggregate following it — the aggregate is app-side presentation +/// convenience, not the storage format. +/// +/// Writes are section-granular by construction: setting `\.theme.matchAppearance` reads the whole +/// `ThemeSettings`, mutates the one field and writes the section back. +struct SettingsData { + + /// The store every property reads and writes through. + private let accessor: any SettingsAccessing + + init(accessor: any SettingsAccessing) { + self.accessor = accessor + } + + /// The general global settings + var general: GeneralSettings { + get { accessor.value(GeneralSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for accounts + var accounts: AccountsSettings { + get { accessor.value(AccountsSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for navigation + var navigation: NavigationSettings { + get { accessor.value(NavigationSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for themes + var theme: ThemeSettings { + get { accessor.value(ThemeSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for text editing + var textEditing: TextEditingSettings { + get { accessor.value(TextEditingSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for the terminal emulator + var terminal: TerminalSettings { + get { accessor.value(TerminalSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for source control + var sourceControl: SourceControlSettings { + get { accessor.value(SourceControlSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// The global settings for keybindings + var keybindings: KeybindingsSettings { + get { accessor.value(KeybindingsSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// Search Settings + var search: SearchSettings { + get { accessor.value(SearchSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// Language Server Settings + var languageServers: LanguageServerSettings { + get { accessor.value(LanguageServerSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } + + /// Developer settings for CodeEdit developers + var developerSettings: DeveloperSettings { + get { accessor.value(DeveloperSettings.self) } + nonmutating set { accessor.setValue(newValue) } + } +} diff --git a/CodeEdit/Features/Settings/Views/SettingsForm.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsForm.swift similarity index 92% rename from CodeEdit/Features/Settings/Views/SettingsForm.swift rename to CodeEdit/AuxiliaryWindows/Settings/SettingsForm.swift index ee093864a9..6650e3e2df 100644 --- a/CodeEdit/Features/Settings/Views/SettingsForm.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/SettingsForm.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI import SwiftUIIntrospect struct SettingsForm: View { @@ -76,11 +77,3 @@ struct SettingsForm: View { } } } - -struct ViewOffsetKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} diff --git a/CodeEdit/AuxiliaryWindows/Settings/SettingsInjector.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsInjector.swift new file mode 100644 index 0000000000..8cc71b09c1 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/SettingsInjector.swift @@ -0,0 +1,64 @@ +// +// SettingsInjector.swift +// CodeEdit +// +// Created by Wouter Hennen on 28/04/2023. +// + +import SwiftUI +import CodeEditSettings + +/// Supplies the settings seam to a view subtree, and re-renders it when settings change. +/// +/// Wrap every **standalone** `NSHostingView`/`NSHostingController` root in this: `@Environment` does +/// not cross a hosting boundary, so a root that skips it hands its whole subtree +/// `DefaultSettingsReader` — plausible defaults for reads, silently discarded writes. +struct SettingsInjector: View { + + /// Observed, not merely held: this view's job is to re-inject `revision` when it changes. + @ObservedObject var store: PersistentSettingsStore + + @ViewBuilder var content: Content + + init(store: PersistentSettingsStore, @ViewBuilder content: () -> Content) { + self.store = store + self.content = content() + } + + var body: some View { + content + // One injection carries both the value and the change signal. SwiftUI subscribes to + // the object itself, which is what the retired pair of environment keys — an accessor + // plus an `Int` revision — needed two keys and a hand-maintained counter to express. + .environmentObject(store) + } +} + +/// The scene-level counterpart of ``SettingsInjector``. +/// +/// The app's scenes are not inside any hosting root, so they need their own injector — and it must +/// be a `Scene`, since a `View` cannot wrap one. `CodeEditApp` can *reach* the store (it does, in +/// `init`, through the `NSApplicationDelegateAdaptor`), but it cannot `@ObservedObject` it: the +/// store is not one of its stored properties, and a property wrapper cannot be attached to a value +/// obtained from another one. So the observation lives here instead. +/// +/// Note what this does **not** cover: `.commands { }` is attached beside a scene's content, not +/// inside it, so these `.environment` values are not documented to reach `Commands` conformers. +/// `CodeEditCommands` therefore takes the store by initializer — see its documentation. +struct SettingsSceneInjector: Scene { + + /// Observed, not merely held: this scene's job is to re-inject `revision` when it changes. + @ObservedObject var store: PersistentSettingsStore + + var content: Content + + init(store: PersistentSettingsStore, @SceneBuilder content: () -> Content) { + self.store = store + self.content = content() + } + + var body: some Scene { + content + .environmentObject(store) + } +} diff --git a/CodeEdit/Features/Settings/Models/SettingsPage.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsPage.swift similarity index 100% rename from CodeEdit/Features/Settings/Models/SettingsPage.swift rename to CodeEdit/AuxiliaryWindows/Settings/SettingsPage.swift diff --git a/CodeEdit/Features/Settings/Views/SettingsPageView.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsPageView.swift similarity index 95% rename from CodeEdit/Features/Settings/Views/SettingsPageView.swift rename to CodeEdit/AuxiliaryWindows/Settings/SettingsPageView.swift index caf1c46e40..c6ab6c9701 100644 --- a/CodeEdit/Features/Settings/Views/SettingsPageView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/SettingsPageView.swift @@ -1,11 +1,12 @@ // -// SettingPageView.swift +// SettingsPageView.swift // CodeEdit // // Created by Austin Condiff on 3/31/23. // import SwiftUI +import CodeEditUI struct SettingsPageView: View { var page: SettingsPage diff --git a/CodeEdit/Features/Settings/Models/SettingsSidebarFix.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsSidebarFix.swift similarity index 100% rename from CodeEdit/Features/Settings/Models/SettingsSidebarFix.swift rename to CodeEdit/AuxiliaryWindows/Settings/SettingsSidebarFix.swift diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsView.swift similarity index 96% rename from CodeEdit/Features/Settings/SettingsView.swift rename to CodeEdit/AuxiliaryWindows/Settings/SettingsView.swift index 9caf15464c..938280ee42 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/SettingsView.swift @@ -6,9 +6,13 @@ // import SwiftUI +import CodeEditSettings /// A struct for settings struct SettingsView: View { + @Environment(\.registryManager) + private var registryManager + @StateObject var model = SettingsViewModel() @Environment(\.colorScheme) private var colorScheme @@ -101,8 +105,6 @@ struct SettingsView: View { ), ] - @ObservedObject private var settings: Settings = .shared - let updater: SoftwareUpdater /// Searches through an array of pages to check if a page name exists in the array @@ -199,7 +201,9 @@ struct SettingsView: View { case .location: LocationsSettingsView() case .languageServers: - LanguageServersView() + if let registryManager { + LanguageServersView(registryManager: registryManager, registryState: registryManager.viewState) + } case .developer: DeveloperSettingsView() default: diff --git a/CodeEdit/Features/Settings/SettingsWindow.swift b/CodeEdit/AuxiliaryWindows/Settings/SettingsWindow.swift similarity index 85% rename from CodeEdit/Features/Settings/SettingsWindow.swift rename to CodeEdit/AuxiliaryWindows/Settings/SettingsWindow.swift index ab34f579bb..011e627f97 100644 --- a/CodeEdit/Features/Settings/SettingsWindow.swift +++ b/CodeEdit/AuxiliaryWindows/Settings/SettingsWindow.swift @@ -8,7 +8,11 @@ import SwiftUI struct SettingsWindow: Scene { - private let updater = SoftwareUpdater() + private let updater: SoftwareUpdater + + init(updater: SoftwareUpdater) { + self.updater = updater + } var body: some Scene { Window("Settings", id: SceneID.settings.rawValue) { diff --git a/CodeEdit/AuxiliaryWindows/Settings/TextEditingSettings+CommandRegistration.swift b/CodeEdit/AuxiliaryWindows/Settings/TextEditingSettings+CommandRegistration.swift new file mode 100644 index 0000000000..a1f6ec4188 --- /dev/null +++ b/CodeEdit/AuxiliaryWindows/Settings/TextEditingSettings+CommandRegistration.swift @@ -0,0 +1,60 @@ +// +// TextEditingSettings+CommandRegistration.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import CodeEditCore +import CodeEditSettings + +extension TextEditingSettings { + /// Registers toggle-able text-editing preferences with the command palette. + /// Invoked once at app startup (previously ran as a side effect of decoding). + /// + /// `settings` is captured by the command closures, which outlive this call — the composition + /// root owns the store, so the capture is of the one live store, not a copy of its values. + static func registerCommands(in mgr: CommandManaging, settings: SettingsAccessing) { + func toggle(_ keyPath: WritableKeyPath) { + var section = settings.value(TextEditingSettings.self) + section[keyPath: keyPath].toggle() + settings.setValue(section) + } + + mgr.addCommand( + name: "Toggle Type-Over Completion", + title: "Toggle Type-Over Completion", + id: "prefs.text_editing.type_over_completion" + ) { + toggle(\.enableTypeOverCompletion) + } + mgr.addCommand( + name: "Toggle Autocomplete Braces", + title: "Toggle Autocomplete Braces", + id: "prefs.text_editing.autocomplete_braces" + ) { + toggle(\.autocompleteBraces) + } + mgr.addCommand( + name: "Toggle Word Wrap", + title: "Toggle Word Wrap", + id: "prefs.text_editing.wrap_lines_to_editor_width" + ) { + toggle(\.wrapLinesToEditorWidth) + } + mgr.addCommand(name: "Toggle Minimap", title: "Toggle Minimap", id: "prefs.text_editing.toggle_minimap") { + toggle(\.showMinimap) + } + mgr.addCommand(name: "Toggle Gutter", title: "Toggle Gutter", id: "prefs.text_editing.toggle_gutter") { + toggle(\.showGutter) + } + mgr.addCommand( + name: "Toggle Folding Ribbon", + title: "Toggle Folding Ribbon", + id: "prefs.text_editing.toggle_folding_ribbon" + ) { + toggle(\.showFoldingRibbon) + } + } +} diff --git a/CodeEdit/Features/Settings/Views/View+ConstrainHeightToWindow.swift b/CodeEdit/AuxiliaryWindows/Settings/View+ConstrainHeightToWindow.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/View+ConstrainHeightToWindow.swift rename to CodeEdit/AuxiliaryWindows/Settings/View+ConstrainHeightToWindow.swift diff --git a/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift b/CodeEdit/AuxiliaryWindows/Settings/View+HideSidebarToggle.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift rename to CodeEdit/AuxiliaryWindows/Settings/View+HideSidebarToggle.swift diff --git a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift b/CodeEdit/AuxiliaryWindows/Settings/View+NavigationBarBackButtonVisible.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift rename to CodeEdit/AuxiliaryWindows/Settings/View+NavigationBarBackButtonVisible.swift diff --git a/CodeEdit/Features/Welcome/GitCloneButton.swift b/CodeEdit/AuxiliaryWindows/Welcome/GitCloneButton.swift similarity index 70% rename from CodeEdit/Features/Welcome/GitCloneButton.swift rename to CodeEdit/AuxiliaryWindows/Welcome/GitCloneButton.swift index 08293aafcb..de31155165 100644 --- a/CodeEdit/Features/Welcome/GitCloneButton.swift +++ b/CodeEdit/AuxiliaryWindows/Welcome/GitCloneButton.swift @@ -5,7 +5,10 @@ // Created by Giorgi Tchelidze on 07.06.25. // +import CESourceControl +import CodeEditCore import SwiftUI +import ShellClient import WelcomeWindow struct GitCloneButton: View { @@ -13,6 +16,9 @@ struct GitCloneButton: View { @State private var showGitClone = false @State private var showCheckoutBranchItem: URL? + let windowManager: any WorkspaceWindowManaging + let shellClient: ShellClientProtocol + var dismissWindow: () -> Void var body: some View { @@ -25,19 +31,21 @@ struct GitCloneButton: View { ) .sheet(isPresented: $showGitClone) { GitCloneView( + shellClient: shellClient, openBranchView: { url in showCheckoutBranchItem = url }, openDocument: { url in - CodeEditDocumentController.shared.openDocument(at: url, onCompletion: { dismissWindow() }) + windowManager.openDocument(at: url, onCompletion: { dismissWindow() }) } ) } .sheet(item: $showCheckoutBranchItem) { url in GitCheckoutBranchView( repoLocalPath: url, + shellClient: shellClient, openDocument: { url in - CodeEditDocumentController.shared.openDocument(at: url, onCompletion: { dismissWindow() }) + windowManager.openDocument(at: url, onCompletion: { dismissWindow() }) } ) } diff --git a/CodeEdit/Features/Welcome/NewFileButton.swift b/CodeEdit/AuxiliaryWindows/Welcome/NewFileButton.swift similarity index 69% rename from CodeEdit/Features/Welcome/NewFileButton.swift rename to CodeEdit/AuxiliaryWindows/Welcome/NewFileButton.swift index 75261faee5..4180f36a36 100644 --- a/CodeEdit/Features/Welcome/NewFileButton.swift +++ b/CodeEdit/AuxiliaryWindows/Welcome/NewFileButton.swift @@ -10,6 +10,8 @@ import WelcomeWindow struct NewFileButton: View { + let windowManager: any WorkspaceWindowManaging + var dismissWindow: () -> Void var body: some View { @@ -17,8 +19,8 @@ struct NewFileButton: View { iconName: "plus.square", title: "Create New File...", action: { - let documentController = CodeEditDocumentController() - documentController.createAndOpenNewDocument(onCompletion: { dismissWindow() }) + windowManager.newDocumentFromPanel() + dismissWindow() } ) } diff --git a/CodeEdit/Features/Welcome/OpenFileOrFolderButton.swift b/CodeEdit/AuxiliaryWindows/Welcome/OpenFileOrFolderButton.swift similarity index 75% rename from CodeEdit/Features/Welcome/OpenFileOrFolderButton.swift rename to CodeEdit/AuxiliaryWindows/Welcome/OpenFileOrFolderButton.swift index 78a8b9467e..1db70a7b45 100644 --- a/CodeEdit/Features/Welcome/OpenFileOrFolderButton.swift +++ b/CodeEdit/AuxiliaryWindows/Welcome/OpenFileOrFolderButton.swift @@ -13,6 +13,8 @@ struct OpenFileOrFolderButton: View { @Environment(\.openWindow) private var openWindow + let windowManager: any WorkspaceWindowManaging + var dismissWindow: () -> Void var body: some View { @@ -20,8 +22,9 @@ struct OpenFileOrFolderButton: View { iconName: "folder", title: "Open File or Folder...", action: { - CodeEditDocumentController.shared.openDocumentWithDialog( - configuration: .init(canChooseFiles: true, canChooseDirectories: true), + windowManager.openDocumentWithDialog( + canChooseFiles: true, + canChooseDirectories: true, onDialogPresented: { dismissWindow() }, onCancel: { openWindow(id: DefaultSceneID.welcome) } ) diff --git a/CodeEdit/Features/Welcome/WelcomeSubtitleView.swift b/CodeEdit/AuxiliaryWindows/Welcome/WelcomeSubtitleView.swift similarity index 100% rename from CodeEdit/Features/Welcome/WelcomeSubtitleView.swift rename to CodeEdit/AuxiliaryWindows/Welcome/WelcomeSubtitleView.swift diff --git a/CodeEdit/CodeEdit.entitlements b/CodeEdit/CodeEdit.entitlements index 5c1489ef3b..f8765c8a2d 100644 --- a/CodeEdit/CodeEdit.entitlements +++ b/CodeEdit/CodeEdit.entitlements @@ -2,18 +2,17 @@ - com.apple.security.app-sandbox + com.apple.security.application-groups + + com.apple.security.cs.allow-jit - com.apple.security.files.user-selected.read-write + com.apple.security.cs.disable-library-validation com.apple.security.files.bookmarks.app-scope + com.apple.security.files.user-selected.read-write + com.apple.security.network.client - com.apple.security.application-groups - - app.codeedit.CodeEdit.shared - $(TeamIdentifierPrefix) - diff --git a/CodeEdit/CodeEditApp.swift b/CodeEdit/CodeEditApp.swift deleted file mode 100644 index 17de696d90..0000000000 --- a/CodeEdit/CodeEditApp.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// CodeEditApp.swift -// CodeEdit -// -// Created by Wouter Hennen on 11/03/2023. -// - -import SwiftUI -import WelcomeWindow -import AboutWindow - -@main -struct CodeEditApp: App { - @NSApplicationDelegateAdaptor var appdelegate: AppDelegate - @ObservedObject var settings = Settings.shared - - let updater: SoftwareUpdater = SoftwareUpdater() - - init() { - // Register singleton services before anything else - ServiceContainer.register( - LSPService() - ) - - _ = CodeEditDocumentController.shared - NSMenuItem.swizzle() - NSSplitViewItem.swizzle() - } - - var body: some Scene { - Group { - WelcomeWindow( - subtitleView: { WelcomeSubtitleView() }, - actions: { dismissWindow in - NewFileButton(dismissWindow: dismissWindow) - GitCloneButton(dismissWindow: dismissWindow) - OpenFileOrFolderButton(dismissWindow: dismissWindow) - }, - onDrop: { url, dismissWindow in - Task { - await CodeEditDocumentController.shared.openDocument(at: url, onCompletion: { dismissWindow() }) - } - } - ) - - ExtensionManagerWindow() - - AboutWindow( - subtitleView: { AboutSubtitleView() }, - actions: { - AboutButton(title: "Contributors", destination: { - ContributorsView() - }) - AboutButton(title: "Acknowledgements", destination: { - AcknowledgementsView() - }) - }, - footer: { AboutFooterView() } - ) - - SettingsWindow() - .commands { - CodeEditCommands() - } - } - .environment(\.settings, settings.preferences) // Add settings to each window environment - } -} diff --git a/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift b/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift deleted file mode 100644 index a92b618bd2..0000000000 --- a/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// TaskNotificationModel.swift -// CodeEdit -// -// Created by Tommy Ludwig on 21.06.24. -// - -import Foundation - -/// Represents a notifications or tasks, that are displayed in the activity viewer -struct TaskNotificationModel: Equatable { - var id: String - var title: String - var message: String? - var percentage: Double? - var isLoading: Bool = false -} diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift deleted file mode 100644 index 655a66eab5..0000000000 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ /dev/null @@ -1,252 +0,0 @@ -// -// TaskNotificationHandler.swift -// CodeEdit -// -// Created by Tommy Ludwig on 21.06.24. -// - -import Foundation -import Combine - -/// Manages task-related notifications. -/// -/// This class listens for notifications named `.taskNotification` and performs actions -/// such as creating, updating, or deleting tasks based on the notification's content. -/// -/// When a task is created, it is added to the end of the array. The activity viewer displays -/// only the first item in the array. To immediately display a notification, use the -/// `"action": "createWithPriority"` option to insert the task at the beginning of the array. -/// *Note: This option should be reserved for important notifications only.* -/// -/// It is recommended to use `UUID().uuidString` to generate a unique identifier for each task. -/// This identifier can then be used to update or delete the task. Alternatively, you can use any -/// unique identifier, such as a token sent from a language server. -/// -/// Remember to manage your task notifications appropriately. You should either delete task -/// notifications manually or schedule their deletion in advance using the `deleteWithDelay` method. -/// -/// Some tasks should be restricted to a specific workspace. To do this, specify the `workspace` attribute in the -/// notification's `userInfo` dictionary as a `URL`, or use the `toWorkspace` parameter on -/// ``TaskNotificationHandler/postTask(toWorkspace:action:model:)``. -/// -/// ## Available Methods -/// - `create`: -/// Creates a new Task Notification. -/// Required fields: `id` (String), `action` (String), `title` (String). -/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool), `workspace` (URL). -/// - `createWithPriority`: -/// Creates a new Task Notification and inserts it at the start of the array. -/// This ensures it appears in the activity viewer even if there are other task notifications before it. -/// **Note:** This should only be used for important notifications! -/// Required fields: `id` (String), `action` (String), `title` (String). -/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool), `workspace` (URL). -/// - `update`: -/// Updates an existing task notification. It's important to pass the same `id` to update the correct task. -/// Required fields: `id` (String), `action` (String). -/// Optional fields: `title` (String), `message` (String), `percentage` (Double), `isLoading` (Bool), -/// `workspace` (URL). -/// - `delete`: -/// Deletes an existing task notification. -/// Required fields: `id` (String), `action` (String). -/// Optional field: `workspace` (URL). -/// - `deleteWithDelay`: -/// Deletes an existing task notification after a certain `TimeInterval`. -/// Required fields: `id` (String), `action` (String), `delay` (Double). -/// Optional field: `workspace` (URL). -/// **Important:** When specifying the delay, ensure it's a double. -/// For example, '2' would be invalid because it would count as an integer, use '2.0' instead. -/// -/// ## Example Usage: -/// ```swift -/// let uuidString = UUID().uuidString -/// -/// func createTask() { -/// let userInfo: [String: Any] = [ -/// "id": "uniqueTaskID", -/// "action": "create", -/// "title": "Task Title" -/// ] -/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) -/// } -/// -/// func createTaskWithPriority() { -/// let userInfo: [String: Any] = [ -/// "id": "uniqueTaskID", -/// "action": "createWithPriority", -/// "title": "Priority Task Title" -/// ] -/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) -/// } -/// -/// func updateTask() { -/// var userInfo: [String: Any] = [ -/// "id": "uniqueTaskID", -/// "action": "update", -/// "title": "Updated Task Title", -/// "message": "Updated Task Message", -/// "percentage": 0.5, -/// "isLoading": true -/// ] -/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) -/// } -/// -/// func deleteTask() { -/// let userInfo: [String: Any] = [ -/// "id": "uniqueTaskID", -/// "action": "delete" -/// ] -/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) -/// } -/// -/// func deleteTaskWithDelay() { -/// let userInfo: [String: Any] = [ -/// "id": "uniqueTaskID", -/// "action": "deleteWithDelay", -/// "delay": 4.0 // 4 would be invalid, because it would count as an int -/// ] -/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) -/// } -/// ``` -/// -/// You can also use the static helper method instead of creating dictionaries manually: -/// ```swift -/// TaskNotificationHandler.postTask(action: .create, model: .init(id: "task_id", "title": "New Task")) -/// ``` -/// -/// - Important: Please refer to ``CodeEdit/TaskNotificationModel`` and ensure you pass the correct values. -final class TaskNotificationHandler: ObservableObject { - @Published private(set) var notifications: [TaskNotificationModel] = [] - var workspaceURL: URL? - var cancellables: Set = [] - - enum Action: String { - case create - case createWithPriority - case update - case delete - case deleteWithDelay - } - - /// Post a new task. - /// - Parameters: - /// - toWorkspace: The workspace to restrict the task to. Defaults to `nil`, which is received by all workspaces. - /// - action: The action being taken on the task. - /// - model: The task contents. - @MainActor - static func postTask(toWorkspace: URL? = nil, action: Action, model: TaskNotificationModel) { - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: [ - "id": model.id, - "title": model.title, - "message": model.message as Any, - "percentage": model.percentage as Any, - "isLoading": model.isLoading, - "action": action.rawValue, - "workspace": toWorkspace as Any - ]) - } - - /// Initialises a new `TaskNotificationHandler` and starts observing for task notifications. - init(workspaceURL: URL? = nil) { - self.workspaceURL = workspaceURL - - NotificationCenter.default - .publisher(for: .taskNotification) - .receive(on: DispatchQueue.main) - .sink { notification in - self.handleNotification(notification) - } - .store(in: &cancellables) - } - - deinit { - NotificationCenter.default.removeObserver(self, name: .taskNotification, object: nil) - } - - /// Handles notifications about task events. - /// - /// - Parameter notification: The notification containing task information. - private func handleNotification(_ notification: Notification) { - guard let userInfo = notification.userInfo, - let taskID = userInfo["id"] as? String, - let actionRaw = userInfo["action"] as? String, - let action = Action(rawValue: actionRaw) else { return } - - // If a workspace is specified and doesn't match, don't do anything with this task. - if let workspaceURL = userInfo["workspace"] as? URL, workspaceURL != self.workspaceURL { - return - } - - switch action { - case .create, .createWithPriority: - createTask(task: userInfo) - case .update: - updateTask(task: userInfo) - case .delete: - deleteTask(taskID: taskID) - case .deleteWithDelay: - if let delay = userInfo["delay"] as? Double { - deleteTaskAfterDelay(taskID: taskID, delay: delay) - } - } - } - - /// Creates a new task or inserts it at the beginning of the tasks array based on the action. - /// - /// - Parameter task: A dictionary containing task information. - private func createTask(task: [AnyHashable: Any]) { - guard let title = task["title"] as? String, - let id = task["id"] as? String, - let action = task["action"] as? String else { - return - } - - let task = TaskNotificationModel( - id: id, - title: title, - message: task["message"] as? String, - percentage: task["percentage"] as? Double, - isLoading: task["isLoading"] as? Bool ?? false - ) - - if action == "create" { - notifications.append(task) - } else { - notifications.insert(task, at: 0) - } - } - - /// Updates an existing task with new information. - /// - /// - Parameter task: A dictionary containing task information. - private func updateTask(task: [AnyHashable: Any]) { - guard let taskID = task["id"] as? String else { return } - if let index = self.notifications.firstIndex(where: { $0.id == taskID }) { - if let title = task["title"] as? String { - self.notifications[index].title = title - } - if let message = task["message"] as? String { - self.notifications[index].message = message - } - if let percentage = task["percentage"] as? Double { - self.notifications[index].percentage = percentage - } - if let isLoading = task["isLoading"] as? Bool { - self.notifications[index].isLoading = isLoading - } - } - } - - private func deleteTask(taskID: String) { - self.notifications.removeAll { $0.id == taskID } - } - - private func deleteTaskAfterDelay(taskID: String, delay: Double) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - self.notifications.removeAll { $0.id == taskID } - } - } -} - -extension Notification.Name { - static let taskNotification = Notification.Name("taskNotification") -} diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift deleted file mode 100644 index ce3a4d7c94..0000000000 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ /dev/null @@ -1,316 +0,0 @@ -// -// FileItem.swift -// CodeEdit -// -// Created by Matthijs Eikelenboom on 07/02/2023. -// - -import Foundation -import SwiftUI -import UniformTypeIdentifiers -import Combine - -/// An object containing all necessary information and actions for a specific file in the workspace -/// -/// The ``CEWorkspaceFile`` represents every type of file that can exist on the file system. Directories, files, -/// symlinks, etc. This class does not assume anything about what it is representing, but it can be interrogated to find -/// out what it represents. All information about the file is derived from the `URL` passed to the initializer of the -/// object. -/// -/// This object works to provide a consistent API for any component that needs to work with files, and is as small as -/// possible. -/// -/// These objects should be fetched from the ``CEWorkspaceFileManager`` whenever possible. Objects fetched from there -/// will be connected in CodeEdit's file tree, and structural properties like ``CEWorkspaceFile/parent`` will exist. -/// They can, however, be created standalone when necessary. Creating a standalone ``CEWorkspaceFile`` is useful if -/// loading all intermediate subdirectories (from the nearest cached parent to the file) has not been done yet and doing -/// so would be unnecessary. -/// -/// An example of this is in the ``OpenQuicklyView``. This view finds a file URL via a search bar, and needs to display -/// a quick preview of the file. There's a good chance the file is deep in some subdirectory of the workspace, so -/// fetching it from the ``CEWorkspaceFileManager`` may require loading and caching multiple directories. Instead, it -/// just makes a disconnected object and uses it for the preview. Then, when opening the file in the workspace it -/// forces the file to be loaded and cached. -final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, EditorTabRepresentable { - - /// The id of the ``CEWorkspaceFile``. - var id: String - - /// Returns the file name (e.g.: `Package.swift`) - var name: String { url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) } - - /// Returns the extension of the file or an empty string if no extension is present. - var type: FileIcon.FileType { - let filename = url.fileName - - /// First, check if there is a valid file extension. - if let type = FileIcon.FileType(rawValue: filename) { - return type - } else { - /// If there's not, verifies every extension for a valid type. - let extensions = filename.dropFirst().components(separatedBy: ".").reversed() - - return extensions - .compactMap { FileIcon.FileType(rawValue: $0) } - .first - /// Returns .txt for invalid type. - ?? .txt - } - } - - /// Returns the URL of the ``CEWorkspaceFile`` - let url: URL - - /// Returns the resolved symlink url of this object. - lazy var resolvedURL: URL = { - url.isSymbolicLink ? url.resolvingSymlinksInPath() : url - }() - - /// Return the icon of the file as `Image` - var icon: Image { - if let customImage = NSImage.symbol(named: systemImage) { - return Image(nsImage: customImage) - } else { - return Image(systemName: systemImage) - } - } - - /// Return the icon of the file as `NSImage` - var nsIcon: NSImage { - if let customImage = NSImage.symbol(named: systemImage) { - return customImage - } else { - return NSImage(systemSymbolName: systemImage, accessibilityDescription: systemImage) - ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! - } - } - - /// Returns a parent ``CEWorkspaceFile``. - /// - /// If the item already is the top-level ``CEWorkspaceFile`` this returns `nil`. - weak var parent: CEWorkspaceFile? - - private let fileDocumentSubject = PassthroughSubject() - - weak var fileDocument: CodeFileDocument? { - didSet { - fileDocumentSubject.send(fileDocument) - } - } - - /// Publisher for fileDocument property - var fileDocumentPublisher: AnyPublisher { - fileDocumentSubject.eraseToAnyPublisher() - } - - var fileIdentifier = UUID().uuidString - - /// Returns the Git status of a file as ``GitStatus`` - var gitStatus: GitStatus? - - /// Returns a boolean that is true if the file is staged for commit - var staged: Bool? - - /// Returns the `id` in ``EditorTabID`` enum form - var tabID: EditorTabID { .codeEditor(id) } - - /// Returns a boolean that is true if the resource represented by this object is a directory. - lazy var isFolder: Bool = { - resolvedURL.isFolder - }() - - /// Returns a boolean that is true if the contents of the directory at this path are - /// - /// Does not indicate if this is a folder, see ``isFolder`` to first check if this object is also a directory. - var isEmptyFolder: Bool { - (try? CEWorkspaceFile.fileManager.contentsOfDirectory( - at: resolvedURL, - includingPropertiesForKeys: nil, - options: .skipsSubdirectoryDescendants - ).isEmpty) ?? true - } - - /// Returns a boolean that is true if the file item is the root folder of the workspace. - var isRoot: Bool { parent == nil } - - /// Returns a boolean that is true if the file item actually exists in the file system - var doesExist: Bool { CEWorkspaceFile.fileManager.fileExists(atPath: self.url.path) } - - /// Returns a string describing a SFSymbol for the current ``CEWorkspaceFile`` - /// - /// Use it like this - /// ```swift - /// Image(systemName: item.systemImage) - /// ``` - var systemImage: String { - if isFolder { - // item is a folder - return folderIcon() - } else { - // item is a file - return FileIcon.fileIcon(fileType: type) - } - } - - /// Return the file's UTType - var contentType: UTType? { - url.contentType - } - - /// Returns a `Color` for a specific `fileType` - /// - /// If not specified otherwise this will return `Color.accentColor` - var iconColor: Color { - FileIcon.iconColor(fileType: type) - } - - init( - id: String, - url: URL, - changeType: GitStatus? = nil, - staged: Bool? = false - ) { - self.id = id - self.url = url - self.gitStatus = changeType - self.staged = staged - } - - convenience init( - url: URL, - changeType: GitStatus? = nil, - staged: Bool? = false - ) { - self.init( - id: url.relativePath, - url: url, - changeType: changeType, - staged: staged - ) - } - - enum CodingKeys: String, CodingKey { - case id - case name - case url - case changeType - case staged - } - - required init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - id = try values.decode(String.self, forKey: .id) - url = try values.decode(URL.self, forKey: .url) - gitStatus = try values.decode(GitStatus.self, forKey: .changeType) - staged = try values.decode(Bool.self, forKey: .staged) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(url, forKey: .url) - try container.encode(gitStatus, forKey: .changeType) - try container.encode(staged, forKey: .staged) - } - - /// Returns a string describing a SFSymbol for folders - /// - /// If it is the top-level folder this will return `"square.dashed.inset.filled"`. - /// If it is a `.codeedit` folder this will return `"folder.fill.badge.gearshape"`. - /// If it has children this will return `"folder.fill"` otherwise `"folder"`. - private func folderIcon() -> String { - if self.parent == nil { - return "folder.fill.badge.gearshape" - } - if self.name == ".codeedit" { - return "folder.fill.badge.gearshape" - } - return isEmptyFolder ? "folder" : "folder.fill" - } - - /// Returns the file name with optional extension (e.g.: `Package.swift`) - func fileName(typeHidden: Bool = false) -> String { - typeHidden ? url.deletingPathExtension() - .lastPathComponent - .trimmingCharacters(in: .whitespacesAndNewlines) : name - } - - /// Generates a string based on user's file name preferences. - /// - Returns: A `String` suitable for display. - func labelFileName() -> String { - let prefs = Settings.shared.preferences.general - switch prefs.fileExtensionsVisibility { - case .hideAll: - return self.fileName(typeHidden: true) - case .showAll: - return self.fileName(typeHidden: false) - case .showOnly: - return self.fileName(typeHidden: !prefs.shownFileExtensions.extensions.contains(self.type.rawValue)) - case .hideOnly: - return self.fileName(typeHidden: prefs.hiddenFileExtensions.extensions.contains(self.type.rawValue)) - } - } - - func validateFileName(for newName: String) -> Bool { - // Name must be: new, nonempty, valid characters, and not exist in the filesystem. - guard newName != labelFileName() && - !newName.isEmpty && - newName.isValidFilename && - !FileManager.default.fileExists( - atPath: self.url.deletingLastPathComponent().appending(path: newName).path - ) else { - return false - } - - return true - } - - /// Loads the ``fileDocument`` property with a new ``CodeFileDocument`` and registers it with the shared - /// ``CodeEditDocumentController``. - func loadCodeFile() throws { - let codeFile = try CodeFileDocument(contentsOf: resolvedURL, ofType: contentType?.identifier ?? "") - CodeEditDocumentController.shared.addDocument(codeFile) - self.fileDocument = codeFile - } - - // MARK: Statics - /// The default `FileManager` instance - static let fileManager = FileManager.default - - // MARK: Intents - /// Allows the user to view the file or folder in the finder application - func showInFinder() { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - /// Allows the user to launch the file or folder as it would be in finder - func openWithExternalEditor() { - NSWorkspace.shared.open(url) - } - - /// Nearest folder refers to the parent directory if this is a non-folder item, or itself if the item is a folder. - var nearestFolder: URL { - (self.isFolder ? - self.url : - self.url.deletingLastPathComponent()) - } - - // MARK: Comparable - - static func == (lhs: CEWorkspaceFile, rhs: CEWorkspaceFile) -> Bool { - lhs.id == rhs.id - } - - static func < (lhs: CEWorkspaceFile, rhs: CEWorkspaceFile) -> Bool { - lhs.url.lastPathComponent < rhs.url.lastPathComponent - } - - // MARK: Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(url) - hasher.combine(id) - } - -} diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift deleted file mode 100644 index e26f15af78..0000000000 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// FileIcon.swift -// -// -// Created by Nanashi Li on 2022/05/20. -// - -import SwiftUI - -// TODO: DOCS (Nanashi Li) -enum FileIcon { - - // swiftlint:disable identifier_name - enum FileType: String { - case adb - case aif - case avi - case bash - case c - case cetheme - case clj - case cls - case cs - case css - case d - case dart - case elm - case entitlements - case env - case ex - case example - case f95 - case fs - case gitignore - case go - case gs - case h - case hs - case html - case ico - case java - case jl - case jpeg - case jpg - case js - case json - case jsx - case kt - case l - case LICENSE - case lock - case lsp - case lua - case m - case Makefile - case md - case mid - case mjs - case mk - case mod - case mov - case mp3 - case mp4 - case pas - case pdf - case pl - case plist - case png - case py - case resolved - case rb - case rs - case rtf - case scm - case scpt - case sh - case ss - case strings - case sum - case svg - case swift - case ts - case tsx - case txt = "text" - case vue - case wav - case xcconfig - case yml - case zsh - } - - // swiftlint:enable identifier_name - - /// Returns a string describing a SFSymbol for files - /// If not specified otherwise this will return `"doc"` - static func fileIcon(fileType: FileType?) -> String { // swiftlint:disable:this cyclomatic_complexity function_body_length line_length - switch fileType { - case .json, .yml, .resolved: - return "doc.json" - case .lock: - return "lock.doc" - case .css: - return "curlybraces" - case .js, .mjs: - return "doc.javascript" - case .jsx, .tsx: - return "atom" - case .swift: - return "swift" - case .env, .example: - return "gearshape.fill" - case .gitignore: - return "arrow.triangle.branch" - case .pdf, .png, .jpg, .jpeg, .ico: - return "photo" - case .svg: - return "square.fill.on.circle.fill" - case .entitlements: - return "checkmark.seal" - case .plist: - return "tablecells" - case .md, .txt: - return "doc.plaintext" - case .rtf: - return "doc.richtext" - case .html: - return "chevron.left.forwardslash.chevron.right" - case .LICENSE: - return "key.fill" - case .java: - return "cup.and.saucer" - case .py: - return "doc.python" - case .rb: - return "doc.ruby" - case .strings: - return "text.quote" - case .h: - return "h.square" - case .m: - return "m.square" - case .vue: - return "v.square" - case .go: - return "g.square" - case .sum: - return "s.square" - case .mod: - return "m.square" - case .bash, .sh, .Makefile, .zsh: - return "terminal" - case .rs: - return "r.square" - case .wav, .mp3, .aif, .mid: - return "speaker.wave.2" - case .avi, .mp4, .mov: - return "film" - case .scpt: - return "applescript" - case .xcconfig: - return "gearshape.2" - case .cetheme: - return "paintbrush" - case .adb, .clj, .cls, .cs, .d, .dart, .elm, .ex, .f95, .fs, .gs, .hs, - .jl, .kt, .l, .lsp, .lua, .mk, .pas, .pl, .scm, .ss: - return "doc.plaintext" - default: - return "doc" - } - } - - /// Returns a `Color` for a specific `fileType` - /// If not specified otherwise this will return `Color.accentColor` - static func iconColor(fileType: FileType?) -> Color { // swiftlint:disable:this cyclomatic_complexity - switch fileType { - case .swift, .html: - return .orange - case .java, .jpg, .png, .svg, .ts: - return .blue - case .css: - return .teal - case .js, .mjs, .py, .entitlements, .LICENSE: - return Color.amber - case .json, .resolved, .rb, .strings, .yml: - return Color.scarlet - case .jsx, .tsx: - return .cyan - case .plist, .xcconfig, .sh: - return Color.steel - case .c, .cetheme: - return .purple - case .vue: - return Color(red: 0.255, green: 0.722, blue: 0.514, opacity: 1.0) - case .h: - return Color(red: 0.667, green: 0.031, blue: 0.133, opacity: 1.0) - case .m: - return Color(red: 0.271, green: 0.106, blue: 0.525, opacity: 1.0) - case .go: - return Color(red: 0.02, green: 0.675, blue: 0.757, opacity: 1.0) - case .sum, .mod: - return Color(red: 0.925, green: 0.251, blue: 0.478, opacity: 1.0) - case .Makefile: - return Color(red: 0.937, green: 0.325, blue: 0.314, opacity: 1.0) - case .rs: - return .orange - default: - return Color.steel - } - } -} diff --git a/CodeEdit/Features/CodeEditUI/Views/PopoverContainer.swift b/CodeEdit/Features/CodeEditUI/Views/PopoverContainer.swift deleted file mode 100644 index 7d62b94f56..0000000000 --- a/CodeEdit/Features/CodeEditUI/Views/PopoverContainer.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// PopoverContainer.swift -// CodeEdit -// -// Created by Khan Winter on 8/29/25. -// - -import SwiftUI - -/// Container for SwiftUI views presented in a popover. -/// On tahoe and above, adds the correct container shape. -struct PopoverContainer: View { - let content: () -> ContentView - - init(@ViewBuilder content: @escaping () -> ContentView) { - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - content() - } - .font(.subheadline) - .if(.tahoe) { - $0.padding(13).containerShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - } else: { - $0.padding(5) - } - .frame(minWidth: 215) - } -} diff --git a/CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift b/CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift deleted file mode 100644 index 13ca285116..0000000000 --- a/CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -/// Tracks scroll offset in scrollable views -struct ScrollOffsetPreferenceKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} diff --git a/CodeEdit/Features/CodeEditUI/Views/SettingsTextEditor.swift b/CodeEdit/Features/CodeEditUI/Views/SettingsTextEditor.swift deleted file mode 100644 index 54328c2c82..0000000000 --- a/CodeEdit/Features/CodeEditUI/Views/SettingsTextEditor.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// SettingsTextEditor.swift -// -// -// Created by Andrey Plotnikov on 07.05.2022. -// - -import Foundation -import SwiftUI - -struct SettingsTextEditor: View { - @State private var isFocus: Bool = false - - @Binding var text: String - - init(text: Binding) { - self._text = text - } - - var body: some View { - Representable(text: $text, isFocused: $isFocus) - .overlay(focusOverlay) - } - - private var focusOverlay: some View { - Rectangle().stroke(Color.accentColor.opacity(isFocus ? 0.4 : 0), lineWidth: 2) - } -} - -private extension SettingsTextEditor { - struct Representable: NSViewRepresentable { - - @Binding var text: String - @Binding var isFocused: Bool - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - scrollView.verticalScroller?.alphaValue = 0 - let textView = scrollView.documentView as? NSTextView - textView?.backgroundColor = .windowBackgroundColor - textView?.isEditable = true - textView?.delegate = context.coordinator - textView?.string = text - return scrollView - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - class Coordinator: NSObject, NSTextViewDelegate { - var parent: Representable - - init(parent: Representable) { - self.parent = parent - } - - func textDidBeginEditing(_ notification: Notification) { - parent.isFocused = true - } - - func textDidEndEditing(_ notification: Notification) { - parent.isFocused = false - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - // Update text - self.parent.text = textView.string - } - } - } - -} diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift deleted file mode 100644 index 5a6a3b3b46..0000000000 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ /dev/null @@ -1,355 +0,0 @@ -// -// CodeFileDocument.swift -// CodeEditModules/CodeFile -// -// Created by Rehatbir Singh on 12/03/2022. -// - -import AppKit -import Foundation -import SwiftUI -import UniformTypeIdentifiers -import CodeEditSourceEditor -import CodeEditTextView -import CodeEditLanguages -import Combine -import OSLog -import TextStory - -enum CodeFileError: Error { - case failedToDecode - case failedToEncode - case fileTypeError -} - -@objc(CodeFileDocument) -final class CodeFileDocument: NSDocument, ObservableObject { - struct OpenOptions { - let cursorPositions: [CursorPosition] - } - - static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CodeFileDocument") - - /// Sent when the document is opened. The document will be sent in the notification's object. - static let didOpenNotification = Notification.Name(rawValue: "CodeFileDocument.didOpen") - /// Sent when the document is closed. The document's `fileURL` will be sent in the notification's object. - static let didCloseNotification = Notification.Name(rawValue: "CodeFileDocument.didClose") - - /// The text content of the document, stored as a text storage - /// - /// This is intentionally not a `@Published` variable. If it were published, SwiftUI would do a string - /// compare each time the contents are updated, which could cause a hang on each keystroke if the file is large - /// enough. - /// - /// To receive notifications for content updates, subscribe to one of the publishers on ``contentCoordinator``. - var content: NSTextStorage? - - /// The string encoding of the original file. Used to save the file back to the encoding it was loaded from. - var sourceEncoding: FileEncoding? - - /// The coordinator to use to subscribe to edit events and cursor location events. - /// See ``CodeEditSourceEditor/CombineCoordinator``. - @Published var contentCoordinator: CombineCoordinator = CombineCoordinator() - - /// Used to override detected languages. - @Published var language: CodeLanguage? - - /// Document-specific overridden indent option. - @Published var indentOption: SettingsData.TextEditingSettings.IndentOption? - - /// Document-specific overridden tab width. - @Published var defaultTabWidth: Int? - - /// Document-specific overridden line wrap preference. - @Published var wrapLines: Bool? - - /// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``. - @Published var languageServerObjects: LanguageServerDocumentObjects = .init() - - /// The type of data this file document contains. - /// - /// If its text content is not nil, a `text` UTType is returned. - /// - /// - Note: The UTType doesn't necessarily mean the file extension, it can be the MIME - /// type or any other form of data representation. - var utType: UTType? { - if content != nil { - return .text - } - - guard let fileType, let type = UTType(fileType) else { - return nil - } - - return type - } - - /// Specify options for opening the file such as the initial cursor positions. - /// Nulled by ``CodeFileView`` on first load. - var openOptions: OpenOptions? - - private let isDocumentEditedSubject = PassthroughSubject() - - /// Publisher for isDocumentEdited property - var isDocumentEditedPublisher: AnyPublisher { - isDocumentEditedSubject.eraseToAnyPublisher() - } - - /// A lock that ensures autosave scheduling happens correctly. - private var autosaveTimerLock: NSLock = NSLock() - /// Timer used to schedule autosave intervals. - private var autosaveTimer: Timer? - - // MARK: - NSDocument - - override static var autosavesInPlace: Bool { - Settings.shared.preferences.general.isAutoSaveOn - } - - override var autosavingFileType: String? { - Settings.shared.preferences.general.isAutoSaveOn - ? fileType - : nil - } - - override func makeWindowControllers() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 750, height: 800), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false - ) - let windowController = NSWindowController(window: window) - if let fileURL { - windowController.shouldCascadeWindows = false - windowController.windowFrameAutosaveName = fileURL.path - } - addWindowController(windowController) - - window.contentView = NSHostingView(rootView: SettingsInjector { - WindowCodeFileView(codeFile: self) - }) - - window.makeKeyAndOrderFront(nil) - - if let fileURL, UserDefaults.standard.object(forKey: "NSWindow Frame \(fileURL.path)") == nil { - window.center() - } - } - - // MARK: - Data - - override func data(ofType _: String) throws -> Data { - guard let sourceEncoding, let data = (content?.string as NSString?)?.data(using: sourceEncoding.nsValue) else { - Self.logger.error("Failed to encode contents to \(self.sourceEncoding.debugDescription)") - throw CodeFileError.failedToEncode - } - return data - } - - // MARK: - Read - - /// This function is used for decoding files. - /// It should not throw error as unsupported files can still be opened by QLPreviewView. - override func read(from data: Data, ofType _: String) throws { - var nsString: NSString? - let rawEncoding = NSString.stringEncoding( - for: data, - encodingOptions: [ - .allowLossyKey: false, // Fail if using lossy encoding. - .suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue }, - .useOnlySuggestedEncodingsKey: true - ], - convertedString: &nsString, - usedLossyConversion: nil - ) - guard let validEncoding = FileEncoding(rawEncoding), let nsString else { - Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)") - return - } - self.sourceEncoding = validEncoding - if let content { - registerContentChangeUndo(fileURL: fileURL, nsString: nsString, content: content) - content.mutableString.setString(nsString as String) - } else { - self.content = NSTextStorage(string: nsString as String) - } - NotificationCenter.default.post(name: Self.didOpenNotification, object: self) - } - - /// If this file is already open and being tracked by an undo manager, we register an undo mutation - /// of the entire contents. This allows the user to undo changes that occurred outside of CodeEdit - /// while the file was displayed in CodeEdit. - /// - /// - Note: This is inefficient memory-wise. We could do a diff of the file and only register the - /// mutations that would recreate the diff. However, that would instead be CPU intensive. - /// Tradeoffs. - private func registerContentChangeUndo(fileURL: URL?, nsString: NSString, content: NSTextStorage) { - guard let fileURL else { return } - // If there's an undo manager, register a mutation replacing the entire contents. - let mutation = TextMutation( - string: nsString as String, - range: NSRange(location: 0, length: content.length), - limit: content.length - ) - let undoManager = self.findWorkspace()?.undoRegistration.managerIfExists(forFile: fileURL) - undoManager?.registerMutation(mutation) - } - - // MARK: - Autosave - - /// Triggered when change occurred - override func updateChangeCount(_ change: NSDocument.ChangeType) { - super.updateChangeCount(change) - - if CodeFileDocument.autosavesInPlace { - return - } - - self.isDocumentEditedSubject.send(self.isDocumentEdited) - } - - /// Triggered when changes saved - override func updateChangeCount(withToken changeCountToken: Any, for saveOperation: NSDocument.SaveOperationType) { - super.updateChangeCount(withToken: changeCountToken, for: saveOperation) - - if CodeFileDocument.autosavesInPlace { - return - } - - self.isDocumentEditedSubject.send(self.isDocumentEdited) - } - - /// If ``hasUnautosavedChanges`` is `true` and an autosave has not already been scheduled, schedules a new autosave. - /// If ``hasUnautosavedChanges`` is `false`, cancels any scheduled timers and returns. - /// - /// All operations are done with the ``autosaveTimerLock`` acquired (including the scheduled autosave) to ensure - /// correct timing when scheduling or cancelling timers. - override func scheduleAutosaving() { - autosaveTimerLock.withLock { - if self.hasUnautosavedChanges { - guard autosaveTimer == nil else { return } - autosaveTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] timer in - self?.autosaveTimerLock.withLock { - guard timer.isValid else { return } - self?.autosaveTimer = nil - self?.autosave(withDelegate: nil, didAutosave: nil, contextInfo: nil) - } - } - } else { - autosaveTimer?.invalidate() - autosaveTimer = nil - } - } - } - - // MARK: - External Changes - - /// Handle the notification that the represented file item changed. - /// - /// We check if a file has been modified and can be read again to display to the user. - /// To determine if a file has changed, we check the modification date. If it's different from the stored one, - /// we continue. - /// To determine if we can reload the file, we check if the document has outstanding edits. If not, we reload the - /// file. - override func presentedItemDidChange() { - if fileModificationDate != getModificationDate() { - guard isDocumentEdited else { - fileModificationDate = getModificationDate() - if let fileURL, let fileType { - // This blocks the presented item thread intentionally. If we don't wait, we'll receive more updates - // that the file has changed and we'll end up dispatching multiple reads. - // The presented item thread expects this operation to by synchronous anyways. - - // https://github.com/CodeEditApp/CodeEdit/issues/2091 - // We can't use `.asyncAndWait` on Ventura as it seems the symbol is missing on that platform. - // Could be just for x86 machines. - DispatchQueue.main.sync { - try? self.read(from: fileURL, ofType: fileType) - } - } - return - } - } - - super.presentedItemDidChange() - } - - /// Helper to find the last modified date of the represented file item. - /// - /// Different from `NSDocument.fileModificationDate`. This returns the *current* modification date, whereas the - /// alternative stores the date that existed when we last read the file. - private func getModificationDate() -> Date? { - guard let path = fileURL?.absolutePath else { return nil } - return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date - } - - // MARK: - Close - - override func close() { - super.close() - NotificationCenter.default.post(name: Self.didCloseNotification, object: fileURL) - } - - override func save(_ sender: Any?) { - guard let fileURL else { - super.save(sender) - return - } - - do { - // Get parent directory for cases when entire folders were deleted – and recreate them as needed - let directory = fileURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - - super.save(sender) - } catch { - presentError(error) - } - } - - override func fileNameExtension( - forType typeName: String, - saveOperation: NSDocument.SaveOperationType - ) -> String? { - guard let fileTypeName = Self.fileTypeExtension[typeName] else { - return super.fileNameExtension(forType: typeName, saveOperation: saveOperation) - } - return fileTypeName - } - - /// Determines the code language of the document. - /// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override - /// the file's language. - /// - Returns: The detected code language. - func getLanguage() -> CodeLanguage { - guard let url = fileURL else { - return .default - } - return language ?? CodeLanguage.detectLanguageFrom( - url: url, - prefixBuffer: content?.string.getFirstLines(5), - suffixBuffer: content?.string.getLastLines(5) - ) - } - - func findWorkspace() -> WorkspaceDocument? { - fileURL?.findWorkspace() - } -} - -// MARK: LanguageServerDocument - -extension CodeFileDocument: LanguageServerDocument { - /// A stable string to use when identifying documents with language servers. - /// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI. - var languageServerURI: String? { - fileURL?.lspURI - } -} - -private extension CodeFileDocument { - - static let fileTypeExtension: [String: String?] = [ - "public.make-source": nil - ] -} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift deleted file mode 100644 index e666f7668f..0000000000 --- a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift +++ /dev/null @@ -1,181 +0,0 @@ -// -// CodeEditDocumentController.swift -// CodeEdit -// -// Created by Pavel Kasila on 17.03.22. -// - -import Cocoa -import SwiftUI -import WelcomeWindow - -final class CodeEditDocumentController: NSDocumentController { - @Environment(\.openWindow) - private var openWindow - - @Service var lspService: LSPService - - private let fileManager = FileManager.default - - @MainActor - func createAndOpenNewDocument(onCompletion: @escaping () -> Void) { - guard let newDocumentUrl = self.newDocumentUrl else { return } - - let createdFile = self.fileManager.createFile( - atPath: newDocumentUrl.path, - contents: nil, - attributes: [FileAttributeKey.creationDate: Date()] - ) - - guard createdFile else { - print("Failed to create new document") - return - } - - self.openDocument(withContentsOf: newDocumentUrl, display: true) { _, _, _ in - onCompletion() - } - } - - override func newDocument(_ sender: Any?) { - guard let newDocumentUrl = self.newDocumentUrl else { return } - - let createdFile = self.fileManager.createFile( - atPath: newDocumentUrl.path, - contents: nil, - attributes: [FileAttributeKey.creationDate: Date()] - ) - guard createdFile else { - print("Failed to create new document") - return - } - - self.openDocument(withContentsOf: newDocumentUrl, display: true) { _, _, _ in } - } - - private var newDocumentUrl: URL? { - let panel = NSSavePanel() - guard panel.runModal() == .OK else { - return nil - } - - return panel.url - } - - override func openDocument(_ sender: Any?) { - self.openDocument(onCompletion: { document, documentWasAlreadyOpen in - // TODO: handle errors - - guard let document else { - print("Failed to unwrap document") - return - } - - print(document, documentWasAlreadyOpen) - }, onCancel: {}) - } - - override func openDocument( - withContentsOf url: URL, - display displayDocument: Bool, - completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void - ) { - guard !openFileInExistingWorkspace(url: url) else { - return - } - - super.openDocument(withContentsOf: url, display: displayDocument) { document, documentWasAlreadyOpen, error in - MainActor.assumeIsolated { - if let document { - self.addDocument(document) - } else { - let errorMessage = error?.localizedDescription ?? "unknown error" - print("Unable to open document '\(url)': \(errorMessage)") - } - - RecentsStore.documentOpened(at: url) - completionHandler(document, documentWasAlreadyOpen, error) - } - } - } - - /// Attempt to open the file URL in an open workspace, finding the nearest workspace to open it in if possible. - /// - Parameter url: The file URL to open. - /// - Returns: True, if the document was opened in a workspace. - private func openFileInExistingWorkspace(url: URL) -> Bool { - guard !url.isFolder else { return false } - let workspaces = documents.compactMap({ $0 as? WorkspaceDocument }) - - // Check open workspaces for the file being opened. Sorted by shared components with the url so we - // open the nearest workspace possible. - for workspace in workspaces.sorted(by: { - ($0.fileURL?.sharedComponents(url) ?? 0) > ($1.fileURL?.sharedComponents(url) ?? 0) - }) { - // createIfNotFound will still return `nil` if the files don't share a common ancestor. - if let newFile = workspace.workspaceFileManager?.getFile(url.absolutePath, createIfNotFound: true) { - workspace.editorManager?.openTab(item: newFile) - workspace.showWindows() - return true - } - } - return false - } - - override func removeDocument(_ document: NSDocument) { - super.removeDocument(document) - - if let workspace = document as? WorkspaceDocument, let path = workspace.fileURL?.absoluteURL.path() { - lspService.closeWorkspace(path) - } - - if CodeEditDocumentController.shared.documents.isEmpty { - switch Settings[\.general].reopenWindowAfterClose { - case .showWelcomeWindow: - // Opens the welcome window - openWindow(sceneID: .welcome) - case .quit: - // Quits CodeEdit - NSApplication.shared.terminate(nil) - case .doNothing: break - } - } - } -} - -extension NSDocumentController { - final func openDocument(onCompletion: @escaping (NSDocument?, Bool) -> Void, onCancel: @escaping () -> Void) { - let dialog = NSOpenPanel() - - dialog.title = "Open Workspace or File" - dialog.showsResizeIndicator = true - dialog.showsHiddenFiles = false - dialog.canChooseFiles = true - dialog.canChooseDirectories = true - - dialog.begin { result in - if result == NSApplication.ModalResponse.OK, let url = dialog.url { - self.openDocument(withContentsOf: url, display: true) { document, documentWasAlreadyOpen, error in - if let error { - NSAlert(error: error).runModal() - return - } - - guard let document else { - let alert = NSAlert() - alert.messageText = NSLocalizedString( - "Failed to get document", - comment: "Failed to get document" - ) - alert.runModal() - return - } - onCompletion(document, documentWasAlreadyOpen) - print("Document:", document) - print("Was already open?", documentWasAlreadyOpen) - } - } else if result == NSApplication.ModalResponse.cancel { - onCancel() - } - } - } -} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift deleted file mode 100644 index d8cb37450c..0000000000 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// CodeEditWindowControllerExtensions.swift -// CodeEdit -// -// Created by Austin Condiff on 10/14/23. -// - -import SwiftUI -import Combine - -extension CodeEditWindowController { - /// These are example items that added as commands to command palette - func registerCommands() { - CommandManager.shared.addCommand( - name: "Quick Open", - title: "Quick Open", - id: "quick_open", - command: { [weak self] in self?.openQuickly(nil) } - ) - - CommandManager.shared.addCommand( - name: "Toggle Navigator", - title: "Toggle Navigator", - id: "toggle_left_sidebar", - command: { [weak self] in self?.toggleFirstPanel() } - ) - - CommandManager.shared.addCommand( - name: "Toggle Inspector", - title: "Toggle Inspector", - id: "toggle_right_sidebar", - command: { [weak self] in self?.toggleLastPanel() } - ) - } - - // Listen to changes in all tabs/files - internal func listenToDocumentEdited(workspace: WorkspaceDocument) { - workspace.editorManager?.$activeEditor - .flatMap({ editor in - editor.$tabs - }) - .compactMap({ tab in - Publishers.MergeMany(tab.elements.compactMap({ $0.file.fileDocumentPublisher })) - }) - .switchToLatest() - .compactMap({ fileDocument in - fileDocument?.isDocumentEditedPublisher - }) - .flatMap({ $0 }) - .sink { isDocumentEdited in - if isDocumentEdited { - self.setDocumentEdited(true) - return - } - - self.updateDocumentEdited(workspace: workspace) - } - .store(in: &cancellables) - - // Listen to change of tabs, if closed tab without saving content, - // we also need to recalculate isDocumentEdited - workspace.editorManager?.$activeEditor - .flatMap({ editor in - editor.$tabs - }) - .sink { _ in - self.updateDocumentEdited(workspace: workspace) - } - .store(in: &cancellables) - } - - // Recalculate documentEdited by checking if any tab/file is edited - private func updateDocumentEdited(workspace: WorkspaceDocument) { - let hasEditedDocuments = !(workspace - .editorManager? - .editorLayout - .gatherOpenFiles() - .filter({ $0.fileDocument?.isDocumentEdited == true }) - .isEmpty ?? true) - self.setDocumentEdited(hasEditedDocuments) - } - - @IBAction func openWorkspaceSettings(_ sender: Any) { - guard let window = window, - let workspace = workspace, - let workspaceSettingsManager = workspace.workspaceSettingsManager, - let taskManager = workspace.taskManager - else { return } - - if let workspaceSettingsWindow, workspaceSettingsWindow.isVisible { - workspaceSettingsWindow.makeKeyAndOrderFront(self) - } else { - let settingsWindow = NSWindow() - self.workspaceSettingsWindow = settingsWindow - let contentView = CEWorkspaceSettingsView( - dismiss: { [weak self, weak settingsWindow] in - guard let settingsWindow else { return } - self?.window?.endSheet(settingsWindow) - } - ) - .environmentObject(workspaceSettingsManager) - .environmentObject(workspace) - .environmentObject(taskManager) - - settingsWindow.contentView = NSHostingView(rootView: contentView) - settingsWindow.titlebarAppearsTransparent = true - settingsWindow.setContentSize(NSSize(width: 515, height: 515)) - settingsWindow.setAccessibilityTitle("Workspace Settings") - - window.beginSheet(settingsWindow, completionHandler: nil) - } - } -} - -extension NSToolbarItem.Identifier { - static let toggleFirstSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ToggleFirstSidebarItem") - static let toggleLastSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ToggleLastSidebarItem") - static let stopTaskSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("StopTaskSidebarItem") - static let startTaskSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("StartTaskSidebarItem") - static let itemListTrackingSeparator = NSToolbarItem.Identifier("ItemListTrackingSeparator") - static let branchPicker: NSToolbarItem.Identifier = NSToolbarItem.Identifier("BranchPicker") - static let activityViewer: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ActivityViewer") - static let notificationItem = NSToolbarItem.Identifier("notificationItem") - - static let taskSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("TaskSidebarItem") -} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Listeners.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Listeners.swift deleted file mode 100644 index 70bc18e78f..0000000000 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Listeners.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// WorkspaceDocument+CommandListeners.swift -// CodeEdit -// -// Created by Khan Winter on 6/5/22. -// - -import Foundation -import Combine - -class WorkspaceNotificationModel: ObservableObject { - - @Published var highlightedFileItem: CEWorkspaceFile? - - init() { - highlightedFileItem = nil - } - -} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift deleted file mode 100644 index 2ee8305a5a..0000000000 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// WorkspaceDocument+SearchState.swift -// CodeEdit -// -// Created by Tom Ludwig on 16.01.24. -// - -import Foundation - -extension WorkspaceDocument { - final class SearchState: ObservableObject { - enum IndexStatus: Equatable { - case none - case indexing(progress: Double) - case done - } - - enum FindNavigatorStatus: Equatable { - case none - case searching - case replacing - case found - case replaced(updatedFiles: Int) - case failed(errorMessage: String) - } - - @Published var searchResult: [SearchResultModel] = [] - @Published var searchResultsFileCount: Int = 0 - @Published var searchResultsCount: Int = 0 - /// Stores the user's input, shown when no files are found, and persists across navigation items. - @Published var searchQuery: String = "" - @Published var replaceText: String = "" - - @Published var indexStatus: IndexStatus = .none - - @Published var findNavigatorStatus: FindNavigatorStatus = .none - - @Published var shouldFocusSearchField: Bool = false - - unowned var workspace: WorkspaceDocument - var tempSearchResults = [SearchResultModel]() - var caseSensitive: Bool = false - var indexer: SearchIndexer? - var selectedMode: [SearchModeModel] = [ - .Find, - .Text, - .Containing - ] - - init(_ workspace: WorkspaceDocument) { - self.workspace = workspace - self.indexer = SearchIndexer.Memory.create() - addProjectToIndex() - } - - /// Represents the compare options to be used for find and replace. - /// - /// The `replaceOptions` property is a lazy, computed property that dynamically calculates - /// the compare options based on the values of `selectedMode` and `ignoreCase`. It is used - /// for controlling string replacement behavior for the find and replace functions. - /// - /// - Note: This property is implemented as a lazy property in the main class body because - /// extensions cannot contain stored properties directly. - lazy var replaceOptions: NSString.CompareOptions = { - var options: NSString.CompareOptions = [] - - if selectedMode.second == .RegularExpression { - options.insert(.regularExpression) - } - - if !caseSensitive { - options.insert(.caseInsensitive) - } - - return options - }() - } -} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift deleted file mode 100644 index 4671b57f4f..0000000000 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ /dev/null @@ -1,291 +0,0 @@ -// -// WorkspaceDocument.swift -// CodeEdit -// -// Created by Pavel Kasila on 17.03.22. -// - -import AppKit -import SwiftUI -import Combine -import Foundation -import LanguageServerProtocol - -@objc(WorkspaceDocument) -final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { - @Published var sortFoldersOnTop: Bool = true - /// A string used to filter the displayed files and folders in the project navigator area based on user input. - @Published var navigatorFilter: String = "" - /// Whether the workspace only shows files with changes. - @Published var sourceControlFilter = false - - private var workspaceState: [String: Any] { - get { - let key = "workspaceState-\(self.fileURL?.absoluteString ?? "")" - return UserDefaults.standard.object(forKey: key) as? [String: Any] ?? [:] - } - set { - let key = "workspaceState-\(self.fileURL?.absoluteString ?? "")" - UserDefaults.standard.set(newValue, forKey: key) - } - } - - var workspaceFileManager: CEWorkspaceFileManager? - - var editorManager: EditorManager? = EditorManager() - var statusBarViewModel: StatusBarViewModel? = StatusBarViewModel() - var utilityAreaModel: UtilityAreaViewModel? = UtilityAreaViewModel() - var searchState: SearchState? - var openQuicklyViewModel: OpenQuicklyViewModel? - var commandsPaletteState: QuickActionsViewModel? - var listenerModel: WorkspaceNotificationModel = .init() - var sourceControlManager: SourceControlManager? - - var taskManager: TaskManager? - var workspaceSettingsManager: CEWorkspaceSettings? - var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() - - var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() - - var notificationPanel = NotificationPanelViewModel() - private var cancellables = Set() - - override init() { - super.init() - notificationPanel.workspace = self - - // Observe changes to notification panel - notificationPanel.objectWillChange - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - } - - deinit { - cancellables.forEach { $0.cancel() } - NotificationCenter.default.removeObserver(self) - } - - func getFromWorkspaceState(_ key: WorkspaceStateKey) -> Any? { - return workspaceState[key.rawValue] - } - - func addToWorkspaceState(key: WorkspaceStateKey, value: Any?) { - if let value { - workspaceState.updateValue(value, forKey: key.rawValue) - } else { - workspaceState.removeValue(forKey: key.rawValue) - } - } - - // MARK: NSDocument - - private let ignoredFilesAndDirectory = [ - ".DS_Store" - ] - - override static var autosavesInPlace: Bool { - false - } - - override var isDocumentEdited: Bool { - false - } - - override func makeWindowControllers() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1400, height: 900), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - // Note For anyone hoping to switch back to a Root-SwiftUI window: - // See Commit 0200c87 for more details and to see what was previously here. - // ----- - // Setting the "min size" like this is hacky, but SwiftUI overrides the contentRect and - // any of the built-in window size functions & autosave stuff. So we have to set it like this. - // SwiftUI also ignores this value, so it just manages to set the initial window size. *Hopefully* this - // is fixed in the future. - // ---- - let windowController = CodeEditWindowController( - window: window, - workspace: self - ) - - if let rectString = getFromWorkspaceState(.workspaceWindowSize) as? String { - window.setFrame(NSRectFromString(rectString), display: true, animate: false) - } else { - window.setFrame(NSRect(x: 0, y: 0, width: 1400, height: 900), display: true, animate: false) - window.center() - } - - window.setAccessibilityIdentifier("workspace") - window.setAccessibilityDocument(self.fileURL?.absoluteString) - - self.addWindowController(windowController) - - window.makeKeyAndOrderFront(nil) - } - - // MARK: Set Up Workspace - - private func initWorkspaceState(_ url: URL) throws { - // Ensure the URL ends with a "/" to prevent certain URL(filePath:relativeTo) initializers from - // placing the file one directory above our workspace. This quick fix appends a "/" if needed. - var url = url - if !url.absoluteString.hasSuffix("/") { - url = URL(filePath: url.absoluteURL.path(percentEncoded: false) + "/") - } - - self.fileURL = url - self.displayName = url.lastPathComponent - - let sourceControlManager = SourceControlManager( - workspaceURL: url, - editorManager: editorManager! - ) - - self.workspaceFileManager = .init( - folderUrl: url, - ignoredFilesAndFolders: Set(ignoredFilesAndDirectory), - sourceControlManager: sourceControlManager - ) - self.sourceControlManager = sourceControlManager - sourceControlManager.fileManager = workspaceFileManager - self.searchState = .init(self) - self.openQuicklyViewModel = .init(fileURL: url) - self.commandsPaletteState = .init() - self.workspaceSettingsManager = CEWorkspaceSettings(workspaceURL: url) - if let workspaceSettingsManager { - self.taskManager = TaskManager( - workspaceSettings: workspaceSettingsManager.settings, - workspaceURL: url - ) - } - self.taskNotificationHandler.workspaceURL = url - - workspaceFileManager?.addObserver(undoRegistration) - editorManager?.restoreFromState(self) - utilityAreaModel?.restoreFromState(self) - } - - override func read(from url: URL, ofType typeName: String) throws { - try initWorkspaceState(url) - } - - override func write(to url: URL, ofType typeName: String) throws {} - - // MARK: Close Workspace - - override func close() { - super.close() - editorManager?.saveRestorationState(self) - utilityAreaModel?.saveRestorationState(self) - - cancellables.forEach({ $0.cancel() }) - statusBarViewModel = nil - utilityAreaModel = nil - searchState = nil - editorManager = nil - openQuicklyViewModel = nil - commandsPaletteState = nil - sourceControlManager = nil - workspaceFileManager?.cleanUp() - workspaceFileManager = nil - workspaceSettingsManager?.cleanUp() - workspaceSettingsManager = nil - taskManager = nil - } - - /// Determines the windows should be closed. - /// - /// This method iterates all edited documents If there are any edited documents. - /// - /// A panel giving the user the choice of canceling, discarding changes, or saving is presented while iteration. - /// - /// If the user chooses cancel on the panel, iteration is broken. - /// - /// In the last step, `shouldCloseSelector` is called with true if all documents are clean, otherwise false - /// - /// - Parameters: - /// - windowController: The windowController may be closed. - /// - delegate: The object which is a target of `shouldCloseSelector`. - /// - shouldClose: The callback which receives result of this method. - /// - contextInfo: The additional info which is not used in this method. - override func shouldCloseWindowController( - _ windowController: NSWindowController, - delegate: Any?, - shouldClose shouldCloseSelector: Selector?, - contextInfo: UnsafeMutableRawPointer? - ) { - guard let object = (delegate as? NSObject), - let shouldCloseSelector = shouldCloseSelector, - let contextInfo = contextInfo - else { - super.shouldCloseWindowController( - windowController, - delegate: delegate, - shouldClose: shouldCloseSelector, - contextInfo: contextInfo - ) - return - } - // Save unsaved changes before closing - let editedCodeFiles = editorManager?.editorLayout - .gatherOpenFiles() - .compactMap(\.fileDocument) - .filter(\.isDocumentEdited) ?? [] - - for editedCodeFile in editedCodeFiles { - let shouldClose = UnsafeMutablePointer.allocate(capacity: 1) - shouldClose.initialize(to: true) - defer { - _ = shouldClose.move() - shouldClose.deallocate() - } - // Present a panel giving the user the choice of canceling, discarding changes, or saving. - editedCodeFile.canClose( - withDelegate: self, - shouldClose: #selector(document(_:shouldClose:contextInfo:)), - contextInfo: shouldClose - ) - // pointee becomes false when user select cancel - guard shouldClose.pointee else { - break - } - } - // Invoke shouldCloseSelector at delegate - let implementation = object.method(for: shouldCloseSelector) - let function = unsafeBitCast( - implementation, - to: (@convention(c)(Any, Selector, Any, Bool, UnsafeMutableRawPointer?) -> Void).self - ) - let areAllOpenedCodeFilesClean = editorManager?.editorLayout.gatherOpenFiles() - .compactMap(\.fileDocument) - .allSatisfy { !$0.isDocumentEdited } ?? false - function(object, shouldCloseSelector, self, areAllOpenedCodeFilesClean, contextInfo) - } - - // MARK: NSDocument delegate - - /// Receives result of `canClose` and then, set `shouldClose` to `contextInfo`'s `pointee`. - /// - /// - Parameters: - /// - document: The document may be closed. - /// - shouldClose: The result of user selection. - /// `shouldClose` becomes false if the user selects cancel, otherwise true. - /// - contextInfo: The additional info which will be set `shouldClose`. - /// `contextInfo` must be `UnsafeMutablePointer`. - @objc - func document( - _ document: NSDocument, - shouldClose: Bool, - contextInfo: UnsafeMutableRawPointer - ) { - let opaquePtr = OpaquePointer(contextInfo) - let mutablePointer = UnsafeMutablePointer(opaquePtr) - mutablePointer.pointee = shouldClose - } -} diff --git a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift deleted file mode 100644 index bb273a09a1..0000000000 --- a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// Editor+StateRestoration.swift -// CodeEdit -// -// Created by Khan Winter on 7/3/23. -// - -import Foundation -import SwiftUI -import OrderedCollections - -extension EditorManager { - /// Restores the tab manager from a captured state obtained using `saveRestorationState` - /// - Parameter workspace: The workspace to retrieve state from. - func restoreFromState(_ workspace: WorkspaceDocument) { - defer { - // No matter what, set the workspace on each editor. Even if we fail to read data. - flattenedEditors.forEach { editor in - editor.workspace = workspace - } - } - - do { - guard let data = workspace.getFromWorkspaceState(.openTabs) as? Data else { - return - } - - let state = try JSONDecoder().decode(EditorRestorationState.self, from: data) - - guard !state.groups.isEmpty else { - logger.warning("Empty Editor State found, restoring to clean editor state.") - initCleanState() - return - } - - guard let activeEditor = state.groups.find( - editor: state.activeEditor - ) ?? state.groups.findSomeEditor() else { - logger.warning("Editor state could not restore active editor.") - initCleanState() - return - } - - try fixRestoredEditorLayout(state.groups, workspace: workspace) - - self.editorLayout = state.groups - self.activeEditor = activeEditor - switchToActiveEditor() - } catch { - logger.warning( - "Could not restore editor state from saved data: \(error.localizedDescription, privacy: .public)" - ) - } - } - - /// Fix any hanging files after restoring from saved state. - /// - /// After decoding the state, we're left with `CEWorkspaceFile`s that don't exist in the file manager - /// so this function maps all those to 'real' files. Works recursively on all the tab groups. - /// - Parameters: - /// - group: The tab group to fix. - /// - fileManager: The file manager to use to map files. - private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) throws { - switch group { - case let .one(data): - try fixEditor(data, workspace: workspace) - case let .vertical(splitData): - try splitData.editorLayouts.forEach { group in - try fixRestoredEditorLayout(group, workspace: workspace) - } - case let .horizontal(splitData): - try splitData.editorLayouts.forEach { group in - try fixRestoredEditorLayout(group, workspace: workspace) - } - } - } - - private func findEditorLayout(group: EditorLayout, searchFor id: UUID) throws -> Editor? { - switch group { - case let .one(data): - return data.id == id ? data : nil - case let .vertical(splitData): - return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first - case let .horizontal(splitData): - return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first - } - } - - /// Fixes any hanging files after restoring from saved state. - /// - /// Resolves all file references with the workspace's file manager to ensure any referenced files use their shared - /// object representation. - /// - /// - Parameters: - /// - data: The tab group to fix. - /// - fileManager: The file manager to use to map files.a - private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) throws { - guard let fileManager = workspace.workspaceFileManager else { return } - let resolvedTabs = editor - .tabs - .compactMap({ fileManager.getFile($0.file.url.path(percentEncoded: false), createIfNotFound: true) }) - .map({ EditorInstance(workspace: workspace, file: $0) }) - - for tab in resolvedTabs { - try tab.file.loadCodeFile() - } - - editor.workspace = workspace - editor.tabs = OrderedSet(resolvedTabs) - - if let selectedTab = editor.selectedTab { - if let resolvedFile = fileManager.getFile( - selectedTab.file.url.path(percentEncoded: false), - createIfNotFound: true - ) { - editor.setSelectedTab(resolvedFile) - } else { - editor.setSelectedTab(nil) - } - } - } - - func saveRestorationState(_ workspace: WorkspaceDocument) { - if let data = try? JSONEncoder().encode( - EditorRestorationState(activeEditor: activeEditor.id, groups: editorLayout) - ) { - workspace.addToWorkspaceState(key: .openTabs, value: data) - } else { - workspace.addToWorkspaceState(key: .openTabs, value: nil) - } - } -} - -struct EditorRestorationState: Codable { - var activeEditor: UUID - var groups: EditorLayout -} - -extension EditorLayout: Codable { - fileprivate enum EditorLayoutType: String, Codable { - case one - case vertical - case horizontal - } - - enum CodingKeys: String, CodingKey { - case type - case tabs - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(EditorLayoutType.self, forKey: .type) - switch type { - case .one: - let editor = try container.decode(Editor.self, forKey: .tabs) - self = .one(editor) - case .vertical: - let editor = try container.decode(SplitViewData.self, forKey: .tabs) - self = .vertical(editor) - case .horizontal: - let editor = try container.decode(SplitViewData.self, forKey: .tabs) - self = .horizontal(editor) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case let .one(data): - try container.encode(EditorLayoutType.one, forKey: .type) - try container.encode(data, forKey: .tabs) - case let .vertical(data): - try container.encode(EditorLayoutType.vertical, forKey: .type) - try container.encode(data, forKey: .tabs) - case let .horizontal(data): - try container.encode(EditorLayoutType.horizontal, forKey: .type) - try container.encode(data, forKey: .tabs) - } - } -} - -extension SplitViewData: Codable { - fileprivate enum SplitViewAxis: String, Codable { - case vertical, horizontal - - init(_ swiftUI: Axis) { - switch swiftUI { - case .vertical: self = .vertical - case .horizontal: self = .horizontal - } - } - - var swiftUI: Axis { - switch self { - case .vertical: return .vertical - case .horizontal: return .horizontal - } - } - } - - enum CodingKeys: String, CodingKey { - case editorLayouts - case axis - } - - convenience init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let axis = try container.decode(SplitViewAxis.self, forKey: .axis).swiftUI - let editorLayouts = try container.decode([EditorLayout].self, forKey: .editorLayouts) - self.init(axis, editorLayouts: editorLayouts) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(editorLayouts, forKey: .editorLayouts) - try container.encode(SplitViewAxis(axis), forKey: .axis) - } -} - -extension Editor: Codable { - enum CodingKeys: String, CodingKey { - case tabs - case selectedTab - case id - } - - convenience init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fileURLs = try container.decode([URL].self, forKey: .tabs) - let selectedTab = try? container.decode(URL.self, forKey: .selectedTab) - let id = try container.decode(UUID.self, forKey: .id) - self.init( - files: OrderedSet(fileURLs.map { CEWorkspaceFile(url: $0) }), - selectedTab: selectedTab == nil ? nil : EditorInstance( - workspace: nil, - file: CEWorkspaceFile(url: selectedTab!) - ), - parent: nil, - workspace: nil - ) - self.id = id - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(tabs.map { $0.file.url }, forKey: .tabs) - try container.encode(selectedTab?.file.url, forKey: .selectedTab) - try container.encode(id, forKey: .id) - } -} diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift deleted file mode 100644 index 9977683cab..0000000000 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// HistoryInspectorView.swift -// CodeEdit -// -// Created by Nanashi Li on 2022/03/24. -// -import SwiftUI - -struct HistoryInspectorView: View { - @AppSettings(\.sourceControl.git.showMergeCommitsPerFileLog) - var showMergeCommitsPerFileLog - - @EnvironmentObject private var workspace: WorkspaceDocument - - @EnvironmentObject private var editorManager: EditorManager - - @ObservedObject private var model: HistoryInspectorModel - - @State var selection: GitCommit? - - /// Initialize with GitClient - /// - Parameter gitClient: a GitClient - init() { - self.model = .init() - } - - var body: some View { - Group { - if model.sourceControlManager != nil { - VStack { - if model.commitHistory.isEmpty { - CEContentUnavailableView("No History") - } else { - List(selection: $selection) { - ForEach(model.commitHistory) { commit in - HistoryInspectorItemView(commit: commit, selection: $selection) - .tag(commit) - .listRowSeparator(.hidden) - } - } - } - } - } else { - NoSelectionInspectorView() - } - } - .onReceive(editorManager.activeEditor.objectWillChange) { _ in - Task { - await model.setFile(url: editorManager.activeEditor.selectedTab?.file.url.path()) - } - } - .onChange(of: editorManager.activeEditor) { _, _ in - Task { - await model.setFile(url: editorManager.activeEditor.selectedTab?.file.url.path()) - } - } - .onChange(of: editorManager.activeEditor.selectedTab) { _, _ in - Task { - await model.setFile(url: editorManager.activeEditor.selectedTab?.file.url.path()) - } - } - .task { - await model.setWorkspace(sourceControlManager: workspace.sourceControlManager) - await model.setFile(url: editorManager.activeEditor.selectedTab?.file.url.path) - } - .onChange(of: showMergeCommitsPerFileLog) { _, _ in - Task { - await model.updateCommitHistory() - } - } - } -} diff --git a/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift b/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift deleted file mode 100644 index f9311cea32..0000000000 --- a/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// InspectorTab.swift -// CodeEdit -// -// Created by Wouter Hennen on 02/06/2023. -// - -import SwiftUI -import CodeEditKit -import ExtensionFoundation - -enum InspectorTab: WorkspacePanelTab { - case file - case gitHistory - case internalDevelopment - case uiExtension(endpoint: AppExtensionIdentity, data: ResolvedSidebar.SidebarStore) - - var systemImage: String { - switch self { - case .file: - return "doc" - case .gitHistory: - return "clock" - case .internalDevelopment: - return "hammer" - case .uiExtension(_, let data): - return data.icon ?? "e.square" - } - } - - var id: String { - if case .uiExtension(let endpoint, let data) = self { - return endpoint.bundleIdentifier + data.sceneID - } - return title - } - - var title: String { - switch self { - case .file: - return "File Inspector" - case .gitHistory: - return "History Inspector" - case .internalDevelopment: - return "Internal Development" - case .uiExtension(_, let data): - return data.help ?? data.sceneID - } - } - - var body: some View { - switch self { - case .file: - FileInspectorView() - case .gitHistory: - HistoryInspectorView() - case .internalDevelopment: - InternalDevelopmentInspectorView() - case let .uiExtension(endpoint, data): - ExtensionSceneView(with: endpoint, sceneID: data.sceneID) - } - } -} diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift deleted file mode 100644 index aff6ad06b1..0000000000 --- a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// InspectorAreaView.swift -// CodeEdit -// -// Created by Austin Condiff on 3/21/22. -// - -import SwiftUI - -struct InspectorAreaView: View { - @EnvironmentObject private var workspace: WorkspaceDocument - @EnvironmentObject private var editorManager: EditorManager - @ObservedObject private var extensionManager = ExtensionManager.shared - @ObservedObject public var viewModel: InspectorAreaViewModel - - @AppSettings(\.general.inspectorTabBarPosition) - var sidebarPosition: SettingsData.SidebarTabBarPosition - - @AppSettings(\.developerSettings.showInternalDevelopmentInspector) - var showInternalDevelopmentInspector - - init(viewModel: InspectorAreaViewModel) { - self.viewModel = viewModel - updateTabs() - } - - private func updateTabs() { - var tabs: [InspectorTab] = [.file, .gitHistory] - - if showInternalDevelopmentInspector { - tabs.append(.internalDevelopment) - } - - viewModel.tabItems = tabs + extensionManager - .extensions - .map { ext in - ext.availableFeatures.compactMap { - if case .sidebarItem(let data) = $0, data.kind == .inspector { - return InspectorTab.uiExtension(endpoint: ext.endpoint, data: data) - } - return nil - } - } - .joined() - } - - var body: some View { - WorkspacePanelView( - viewModel: viewModel, - selectedTab: $viewModel.selectedTab, - tabItems: $viewModel.tabItems, - sidebarPosition: sidebarPosition, - sideOnTrailing: true - ) - .formStyle(.grouped) - .accessibilityElement(children: .contain) - .accessibilityLabel("inspector") - .onChange(of: showInternalDevelopmentInspector) { _, _ in - updateTabs() - } - } -} diff --git a/CodeEdit/Features/Keybindings/CommandManager.swift b/CodeEdit/Features/Keybindings/CommandManager.swift deleted file mode 100644 index f21f2ad81d..0000000000 --- a/CodeEdit/Features/Keybindings/CommandManager.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// CommandManager.swift -// -// Created by Alex on 23.05.2022. -// - -import Foundation - -/** -The object of this class intended to be a hearth of command palette. This object only exists as singleton. - In Order to access its instance use `CommandManager.shared` - -``` - /* To add or execute command see snipper below */ -let mgr = CommandManager.shared -let wrap = CommandClosureWrapper.init(closure: { - print("testing closure") -}) - -mgr.addCommand(name: "test", command: wrap) -mgr.executeCommand("test") - ``` - */ - -final class CommandManager: ObservableObject { - @Published private var commandsList: [String: Command] - - private init() { - commandsList = [:] - } - - static let shared: CommandManager = .init() - - func addCommand(name: String, title: String, id: String, command: @escaping () -> Void) { - let command = Command.init(id: name, title: title, closureWrapper: command) - commandsList[id] = command - } - - var commands: [Command] { - return commandsList.map { $0.value } - } - - func executeCommand(_ id: String) { - commandsList[id]?.closureWrapper() - } -} - -/// Command struct uses as a wrapper for command. Used by command palette to call selected commands. -struct Command: Identifiable, Hashable { - - static func == (lhs: Command, rhs: Command) -> Bool { - return lhs.id == rhs.id - } - - static func < (lhs: Command, rhs: Command) -> Bool { - return false - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - let id: String - let title: String - let closureWrapper: () -> Void -} diff --git a/CodeEdit/Features/Keybindings/KeybindingManager.swift b/CodeEdit/Features/Keybindings/KeybindingManager.swift deleted file mode 100644 index 8c3b1bfebc..0000000000 --- a/CodeEdit/Features/Keybindings/KeybindingManager.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// KeybindingManager.swift -// -// Created by Alex on 09.05.2022. -// - -import Foundation -import SwiftUI - -final class KeybindingManager { - /// Array which contains all available keyboard shortcuts - var keyboardShortcuts = [String: KeyboardShortcutWrapper]() - - private init() { - loadKeybindings() - } - - /// Static method to access singleton - static let shared: KeybindingManager = .init() - - // We need this fallback shortcut because optional shortcuts available only from 12.3, while we have target of 12.0x - var fallbackShortcut = KeyboardShortcutWrapper( - name: "?", - description: "Test", - context: "Fallback", - keybinding: "?", - modifier: "shift", - id: "fallback" - ) - - /// Adds new shortcut - func addNewShortcut(shortcut: KeyboardShortcutWrapper, name: String) { - keyboardShortcuts[name] = shortcut - } - - private func loadKeybindings() { - - let bindingsURL = Bundle.main.url(forResource: "default_keybindings.json", withExtension: nil) - if let json = try? Data(contentsOf: bindingsURL!) { - do { - let prefs = try JSONDecoder().decode([KeyboardShortcutWrapper].self, from: json) - for pref in prefs { - addNewShortcut(shortcut: pref, name: pref.id) - } - } catch { - print("error:\(error)") - } - } - return - } - - /// Get shortcut by name - /// - Parameter name: shortcut name - /// - Returns: KeyboardShortcutWrapper - func named(with name: String) -> KeyboardShortcutWrapper { - let foundElement = keyboardShortcuts[name] - return foundElement != nil ? foundElement! : fallbackShortcut - } - -} - -/// Wrapper for KeyboardShortcut. It contains name, keybindings. -struct KeyboardShortcutWrapper: Codable, Hashable { - var keyboardShortcut: KeyboardShortcut { - return KeyboardShortcut.init(.init(Character(keybinding)), modifiers: parsedModifier) - } - - var parsedModifier: EventModifiers { - switch modifier { - case "command": - return EventModifiers.command - case "shift": - return EventModifiers.shift - case "option": - return EventModifiers.option - case "control": - return EventModifiers.control - default: - return EventModifiers.command - } - } - var name: String - var description: String - var context: String - var keybinding: String - var modifier: String - var id: String - - enum CodingKeys: String, CodingKey { - case name - case description - case context - case keybinding - case modifier - case id - } - - init(name: String, description: String, context: String, keybinding: String, modifier: String, id: String) { - self.name = name - self.description = description - self.context = context - self.keybinding = keybinding - self.modifier = modifier - self.id = id - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - description = try container.decode(String.self, forKey: .description) - context = try container.decode(String.self, forKey: .context) - keybinding = try container.decode(String.self, forKey: .keybinding) - modifier = try container.decode(String.self, forKey: .modifier) - id = try container.decode(String.self, forKey: .id) - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift deleted file mode 100644 index 5f9eccfe09..0000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// InstallStepConfirmation.swift -// CodeEdit -// -// Created by Khan Winter on 8/8/25. -// - -enum InstallStepConfirmation { - case none - case required(message: String) -} diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorResultList.swift b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorResultList.swift deleted file mode 100644 index 8e5449ef29..0000000000 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorResultList.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// SearchResultList.swift -// CodeEdit -// -// Created by Ziyuan Zhao on 2022/3/22. -// - -import SwiftUI -import Combine - -struct FindNavigatorResultList: NSViewControllerRepresentable { - - @EnvironmentObject var workspace: WorkspaceDocument - - @AppSettings(\.general.projectNavigatorSize) - var projectNavigatorSize - - typealias NSViewControllerType = FindNavigatorListViewController - - func makeNSViewController(context: Context) -> FindNavigatorListViewController { - let controller = FindNavigatorListViewController(workspace: workspace) - controller.setSearchResults(workspace.searchState?.searchResult ?? []) - controller.rowHeight = projectNavigatorSize.rowHeight - context.coordinator.controller = controller - return controller - } - - func updateNSViewController(_ nsViewController: FindNavigatorListViewController, context: Context) { - nsViewController.updateNewSearchResults( - workspace.searchState?.searchResult ?? [] - ) - if nsViewController.rowHeight != projectNavigatorSize.rowHeight { - nsViewController.rowHeight = projectNavigatorSize.rowHeight - } - return - } - - func makeCoordinator() -> Coordinator { - Coordinator( - state: workspace.searchState, - controller: nil - ) - } - - class Coordinator: NSObject { - init(state: WorkspaceDocument.SearchState?, controller: FindNavigatorListViewController?) { - self.controller = controller - super.init() - self.listener = state? - .$searchResult - .sink(receiveValue: { [weak self] searchResults in - self?.controller?.updateNewSearchResults(searchResults) - }) - } - - var listener: AnyCancellable? - var controller: FindNavigatorListViewController? - - deinit { - controller = nil - listener?.cancel() - listener = nil - } - } -} diff --git a/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift b/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift deleted file mode 100644 index 9f0c90f54f..0000000000 --- a/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// NavigatorTab.swift -// CodeEdit -// -// Created by Wouter Hennen on 02/06/2023. -// - -import SwiftUI -import CodeEditKit -import ExtensionFoundation - -enum NavigatorTab: WorkspacePanelTab { - case project - case sourceControl - case search - case uiExtension(endpoint: AppExtensionIdentity, data: ResolvedSidebar.SidebarStore) - - var systemImage: String { - switch self { - case .project: - return "folder" - case .sourceControl: - return "vault" - case .search: - return "magnifyingglass" - case .uiExtension(_, let data): - return data.icon ?? "e.square" - } - } - - var id: String { - if case .uiExtension(let endpoint, let data) = self { - return endpoint.bundleIdentifier + data.sceneID - } - return title - } - - var title: String { - switch self { - case .project: - return "Project" - case .sourceControl: - return "Source Control" - case .search: - return "Search" - case .uiExtension(_, let data): - return data.help ?? data.sceneID - } - } - - var body: some View { - switch self { - case .project: - ProjectNavigatorView() - case .sourceControl: - SourceControlNavigatorView() - case .search: - FindNavigatorView() - case let .uiExtension(endpoint, data): - ExtensionSceneView(with: endpoint, sceneID: data.sceneID) - } - } - - @ViewBuilder - func bottomView(workspace: WorkspaceDocument) -> some View { - switch self { - case .project: - ProjectNavigatorToolbarBottom() - case .sourceControl: - if let sourceControlManager = workspace.sourceControlManager { - SourceControlNavigatorToolbarBottom() - .environmentObject(sourceControlManager) - } - case .search: - FindNavigatorToolbarBottom() - case .uiExtension: - EmptyView() - } - } -} diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/TextTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/TextTableViewCell.swift deleted file mode 100644 index d18e1cb1b2..0000000000 --- a/CodeEdit/Features/NavigatorArea/OutlineView/TextTableViewCell.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// TextTableViewCell.swift -// CodeEdit -// -// Created by TAY KAI QUAN on 11/9/22. -// - -import SwiftUI - -class TextTableViewCell: NSTableCellView { - - var label: NSTextField! - - init(frame frameRect: NSRect, isEditable: Bool = true, startingText: String = "") { - super.init(frame: frameRect) - setupViews(frame: frameRect, isEditable: isEditable) - self.label.stringValue = startingText - } - - // Default init, assumes isEditable to be false - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setupViews(frame: frameRect, isEditable: false) - } - - private func setupViews(frame frameRect: NSRect, isEditable: Bool) { - // Create the label - label = createLabel() - configLabel(label: self.label, isEditable: isEditable) - self.textField = label - - addSubview(label) - createConstraints(frame: frameRect) - } - - // MARK: Create and config stuff - func createLabel() -> NSTextField { - return NSTextField(frame: .zero) - } - - func configLabel(label: NSTextField, isEditable: Bool) { - label.translatesAutoresizingMaskIntoConstraints = false - label.drawsBackground = false - label.isBordered = false - label.isEditable = isEditable - label.isSelectable = isEditable - label.layer?.cornerRadius = 10.0 - label.font = .boldSystemFont(ofSize: fontSize) - label.lineBreakMode = .byTruncatingMiddle - label.textColor = NSColor.textColor - label.alphaValue = 0.7 - } - - func createConstraints(frame frameRect: NSRect) { - resizeSubviews(withOldSize: .zero) - } - - override func resizeSubviews(withOldSize oldSize: NSSize) { - super.resizeSubviews(withOldSize: oldSize) - label.frame = NSRect( - x: 2, - y: 2.5, - width: frame.width - 4, - height: 25 - ) - } - - /// Returns the font size for the current row height. Defaults to `13.0` - private var fontSize: Double { - switch self.frame.height { - case 20: return 11 - case 22: return 13 - case 24: return 14 - default: return 13 - } - } - - /// *Not Implemented* - required init(coder: NSCoder) { - fatalError(""" - init?(coder: NSCoder) isn't implemented on `TextTableViewCell`. - Please use `.init(frame: NSRect, isEditable: Bool) - """) - } -} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift deleted file mode 100644 index a072d80c27..0000000000 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// OutlineView.swift -// CodeEdit -// -// Created by Lukas Pistrol on 05.04.22. -// - -import SwiftUI -import Combine - -/// Wraps an ``OutlineViewController`` inside a `NSViewControllerRepresentable` -struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { - - @EnvironmentObject var workspace: WorkspaceDocument - @EnvironmentObject var editorManager: EditorManager - - @StateObject var prefs: Settings = .shared - - typealias NSViewControllerType = ProjectNavigatorViewController - - func makeNSViewController(context: Context) -> ProjectNavigatorViewController { - let controller = ProjectNavigatorViewController() - controller.workspace = workspace - controller.iconColor = prefs.preferences.general.fileIconStyle - controller.editor = editorManager.activeEditor - workspace.workspaceFileManager?.addObserver(context.coordinator) - - context.coordinator.controller = controller - - return controller - } - - func updateNSViewController(_ nsViewController: ProjectNavigatorViewController, context: Context) { - nsViewController.iconColor = prefs.preferences.general.fileIconStyle - nsViewController.rowHeight = prefs.preferences.general.projectNavigatorSize.rowHeight - nsViewController.fileExtensionsVisibility = prefs.preferences.general.fileExtensionsVisibility - nsViewController.shownFileExtensions = prefs.preferences.general.shownFileExtensions - nsViewController.hiddenFileExtensions = prefs.preferences.general.hiddenFileExtensions - /// if the window becomes active from background, it will restore the selection to outline view. - nsViewController.updateSelection(itemID: workspace.editorManager?.activeEditor.selectedTab?.file.id) - return - } - - func makeCoordinator() -> Coordinator { - Coordinator(workspace) - } - - class Coordinator: NSObject, CEWorkspaceFileManagerObserver { - init(_ workspace: WorkspaceDocument) { - self.workspace = workspace - super.init() - - workspace.listenerModel.$highlightedFileItem - .sink(receiveValue: { [weak self] fileItem in - guard let fileItem else { - return - } - self?.controller?.reveal(fileItem) - }) - .store(in: &cancellables) - workspace.editorManager?.tabBarTabIdSubject - .sink { [weak self] editorInstance in - self?.controller?.updateSelection(itemID: editorInstance?.file.id) - } - .store(in: &cancellables) - workspace.$navigatorFilter - .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) - .sink { [weak self] _ in - self?.controller?.handleFilterChange() - } - .store(in: &cancellables) - Publishers.Merge(workspace.$sourceControlFilter, workspace.$sortFoldersOnTop) - .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) - .sink { [weak self] _ in - self?.controller?.handleFilterChange() - } - .store(in: &cancellables) - } - - var cancellables: Set = [] - weak var workspace: WorkspaceDocument? - weak var controller: ProjectNavigatorViewController? - - func fileManagerUpdated(updatedItems: Set) { - guard let outlineView = controller?.outlineView else { return } - let selectedRows = outlineView.selectedRowIndexes.compactMap({ outlineView.item(atRow: $0) }) - - // If some text view inside the outline view is first responder right now, push the update off - // until editing is finished using the `shouldReloadAfterDoneEditing` flag. - if outlineView.window?.firstResponder !== outlineView - && outlineView.window?.firstResponder is NSTextView - && (outlineView.window?.firstResponder as? NSView)?.isDescendant(of: outlineView) == true { - controller?.shouldReloadAfterDoneEditing = true - } else { - for item in updatedItems { - outlineView.reloadItem(item, reloadChildren: true) - } - } - - // Restore selected items where the files still exist. - let selectedIndexes = selectedRows.compactMap({ outlineView.row(forItem: $0) }).filter({ $0 >= 0 }) - controller?.shouldSendSelectionUpdate = false - outlineView.selectRowIndexes(IndexSet(selectedIndexes), byExtendingSelection: false) - controller?.shouldSendSelectionUpdate = true - } - - deinit { - workspace?.workspaceFileManager?.removeObserver(self) - } - } -} diff --git a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift deleted file mode 100644 index 12e15850cf..0000000000 --- a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// NavigatorAreaView.swift -// CodeEdit -// -// Created by Lukas Pistrol on 17.03.22. -// - -import SwiftUI - -struct NavigatorAreaView: View { - @ObservedObject private var workspace: WorkspaceDocument - @ObservedObject private var extensionManager = ExtensionManager.shared - @ObservedObject public var viewModel: NavigatorAreaViewModel - - @AppSettings(\.general.navigatorTabBarPosition) - var sidebarPosition: SettingsData.SidebarTabBarPosition - - init(workspace: WorkspaceDocument, viewModel: NavigatorAreaViewModel) { - self.workspace = workspace - self.viewModel = viewModel - - viewModel.tabItems = [.project, .sourceControl, .search] + - extensionManager - .extensions - .map { ext in - ext.availableFeatures.compactMap { - if case .sidebarItem(let data) = $0, data.kind == .navigator { - return NavigatorTab.uiExtension(endpoint: ext.endpoint, data: data) - } - return nil - } - } - .joined() - } - - var body: some View { - WorkspacePanelView( - viewModel: viewModel, - selectedTab: $viewModel.selectedTab, - tabItems: $viewModel.tabItems, - sidebarPosition: sidebarPosition, - sidebarPadding: { - if sidebarPosition == .side { - return (.trailing, 8) - } - - return ([], 0) - }, - bottomAccessory: { - viewModel.selectedTab?.bottomView(workspace: workspace) - } - ) - .listStyle(.inset) - .environmentObject(workspace) - .accessibilityElement(children: .contain) - .accessibilityLabel("navigator") - } -} diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift deleted file mode 100644 index e270514189..0000000000 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// NotificationManager.swift -// CodeEdit -// -// Created by Austin Condiff on 2/10/24. -// - -import SwiftUI -import Combine -import UserNotifications - -/// Manages the application's notification system, handling both in-app notifications and system notifications. -/// This class is responsible for: -/// - Managing notification persistence -/// - Tracking notification read status -/// - Broadcasting notifications to workspaces -final class NotificationManager: NSObject, ObservableObject { - /// Shared instance for accessing the notification manager - static let shared = NotificationManager() - - /// Collection of all notifications, both read and unread - @Published private(set) var notifications: [CENotification] = [] - - private var isAppActive: Bool = true - - /// Number of unread notifications - var unreadCount: Int { - notifications.filter { !$0.isRead }.count - } - - /// Posts a new notification - /// - Parameters: - /// - iconSymbol: SF Symbol or CodeEditSymbol name for the notification icon - /// - iconColor: Color for the icon - /// - title: Main notification title - /// - description: Detailed notification message - /// - actionButtonTitle: Title for the action button - /// - action: Closure to execute when action button is clicked - /// - isSticky: Whether the notification should persist until manually dismissed - func post( - iconSymbol: String, - iconColor: Color? = Color(.systemBlue), - title: String, - description: String, - actionButtonTitle: String, - action: @escaping () -> Void, - isSticky: Bool = false - ) { - let notification = CENotification( - iconSymbol: iconSymbol, - iconColor: iconColor, - title: title, - description: description, - actionButtonTitle: actionButtonTitle, - action: action, - isSticky: isSticky, - isRead: false - ) - - postNotification(notification) - } - - /// Posts a new notification - /// - Parameters: - /// - iconImage: Image for the notification icon - /// - title: Main notification title - /// - description: Detailed notification message - /// - actionButtonTitle: Title for the action button - /// - action: Closure to execute when action button is clicked - /// - isSticky: Whether the notification should persist until manually dismissed - func post( - iconImage: Image, - title: String, - description: String, - actionButtonTitle: String, - action: @escaping () -> Void, - isSticky: Bool = false - ) { - let notification = CENotification( - iconImage: iconImage, - title: title, - description: description, - actionButtonTitle: actionButtonTitle, - action: action, - isSticky: isSticky - ) - - postNotification(notification) - } - - /// Posts a new notification - /// - Parameters: - /// - iconText: Text or emoji for the notification icon - /// - iconTextColor: Color of the text/emoji (defaults to primary label color) - /// - iconColor: Background color for the icon - /// - title: Main notification title - /// - description: Detailed notification message - /// - actionButtonTitle: Title for the action button - /// - action: Closure to execute when action button is clicked - /// - isSticky: Whether the notification should persist until manually dismissed - func post( - iconText: String, - iconTextColor: Color? = nil, - iconColor: Color? = Color(.systemBlue), - title: String, - description: String, - actionButtonTitle: String, - action: @escaping () -> Void, - isSticky: Bool = false - ) { - let notification = CENotification( - iconText: iconText, - iconTextColor: iconTextColor, - iconColor: iconColor, - title: title, - description: description, - actionButtonTitle: actionButtonTitle, - action: action, - isSticky: isSticky - ) - - postNotification(notification) - } - - /// Dismisses a specific notification - func dismissNotification(_ notification: CENotification) { - notifications.removeAll(where: { $0.id == notification.id }) - markAsRead(notification) - - // Remove system notification if it exists - removeSystemNotification(notification) - - NotificationCenter.default.post( - name: .init("NotificationDismissed"), - object: notification - ) - } - - /// Marks a notification as read - /// - Parameter notification: The notification to mark as read - func markAsRead(_ notification: CENotification) { - if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - notifications[index].isRead = true - } - } - - override init() { - super.init() - setupNotificationDelegate() - - // Observe app active state - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAppDidBecomeActive), - name: NSApplication.didBecomeActiveNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAppDidResignActive), - name: NSApplication.didResignActiveNotification, - object: nil - ) - } - - @objc - private func handleAppDidBecomeActive() { - isAppActive = true - // Remove any system notifications when app becomes active - UNUserNotificationCenter.current().removeAllDeliveredNotifications() - } - - @objc - private func handleAppDidResignActive() { - isAppActive = false - } - - /// Posts a notification to workspaces and system - private func postNotification(_ notification: CENotification) { - DispatchQueue.main.async { [weak self] in - self?.notifications.append(notification) - - // Always notify workspaces of new notification - NotificationCenter.default.post( - name: .init("NewNotificationAdded"), - object: notification - ) - - // Additionally show system notification when app is in background - if self?.isAppActive != true { - self?.showSystemNotification(notification) - } - } - } -} diff --git a/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift b/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift deleted file mode 100644 index 6fdcc89143..0000000000 --- a/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift +++ /dev/null @@ -1,313 +0,0 @@ -// -// NotificationPanelViewModel.swift -// CodeEdit -// -// Created by Austin Condiff on 2/14/24. -// - -import SwiftUI - -final class NotificationPanelViewModel: ObservableObject { - /// Currently displayed notifications in the panel - @Published private(set) var activeNotifications: [CENotification] = [] - - /// Whether notifications panel was manually shown via toolbar - @Published private(set) var isPresented: Bool = false - - /// Set of hidden notification IDs - @Published private(set) var hiddenNotificationIds: Set = [] - - /// Timers for notifications - private var timers: [UUID: Timer] = [:] - - /// Display duration for notifications - private let displayDuration: TimeInterval = 5.0 - - /// Whether notifications are paused - private var isPaused: Bool = false - - private var notificationManager = NotificationManager.shared - - @Published var scrolledToTop: Bool = true - - /// A filtered list of active notifications. - var visibleNotifications: [CENotification] { - activeNotifications.filter { !hiddenNotificationIds.contains($0.id) } - } - - weak var workspace: WorkspaceDocument? - - /// Whether a notification should be visible in the panel - func isNotificationVisible(_ notification: CENotification) -> Bool { - if notification.isBeingDismissed { - return true // Always show notifications being dismissed - } - if notification.isSticky { - return true // Always show sticky notifications - } - if isPresented { - return true // Show all notifications when manually shown - } - return !hiddenNotificationIds.contains(notification.id) - } - - /// Handles focus changes for the notification panel - func handleFocusChange(isFocused: Bool) { - if !isFocused { - // Only hide if manually shown and focus is completely lost - if isPresented { - toggleNotificationsVisibility() - } - } - } - - /// Toggles visibility of notifications in the panel - func toggleNotificationsVisibility() { - if isPresented { - if !scrolledToTop { - // Just set isPresented to false to trigger the offset animation - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false - } - - // After the slide-out animation, hide notifications - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - // Hide non-sticky notifications - self.activeNotifications - .filter { !$0.isSticky } - .forEach { self.hiddenNotificationIds.insert($0.id) } - self.objectWillChange.send() - - // After notifications are hidden, reset scroll position - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.scrolledToTop = true - } - } - } else { - // At top, just hide normally - hideNotifications() - } - } else { - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = true - hiddenNotificationIds.removeAll() - objectWillChange.send() - } - } - } - - private func hideNotifications() { - withAnimation(.easeInOut(duration: 0.3)) { - self.isPresented = false - self.activeNotifications - .filter { !$0.isSticky } - .forEach { self.hiddenNotificationIds.insert($0.id) } - self.objectWillChange.send() - } - } - - /// Starts the timer to automatically hide a notification - func startHideTimer(for notification: CENotification) { - guard !notification.isSticky && !isPresented else { return } - - timers[notification.id]?.invalidate() - timers[notification.id] = nil - - guard !isPaused else { return } - - timers[notification.id] = Timer.scheduledTimer( - withTimeInterval: displayDuration, - repeats: false - ) { [weak self] _ in - guard let self = self else { return } - self.timers[notification.id] = nil - - // Ensure we're on the main thread and animate the change - DispatchQueue.main.async { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 - context.allowsImplicitAnimation = true - - withAnimation(.easeInOut(duration: 0.3)) { - var newHiddenIds = self.hiddenNotificationIds - newHiddenIds.insert(notification.id) - self.hiddenNotificationIds = newHiddenIds - } - } - } - } - } - - /// Pauses all auto-hide timers - func pauseTimer() { - isPaused = true - timers.values.forEach { $0.invalidate() } - } - - /// Resumes all auto-hide timers - func resumeTimer() { - isPaused = false - // Only restart timers for notifications that are currently visible - activeNotifications - .filter { !$0.isSticky && isNotificationVisible($0) } - .forEach { startHideTimer(for: $0) } - } - - /// Inserts a notification in the correct position (sticky notifications on top) - private func insertNotification(_ notification: CENotification) { - if notification.isSticky { - // Find the first sticky notification (to insert before it) - if let firstStickyIndex = activeNotifications.firstIndex(where: { $0.isSticky }) { - // Insert at the very start of sticky group - activeNotifications.insert(notification, at: firstStickyIndex) - } else { - // No sticky notifications yet, insert at the start - activeNotifications.insert(notification, at: 0) - } - } else { - // Find the first non-sticky notification - if let firstNonStickyIndex = activeNotifications.firstIndex(where: { !$0.isSticky }) { - // Insert at the start of non-sticky group - activeNotifications.insert(notification, at: firstNonStickyIndex) - } else { - // No non-sticky notifications yet, append at the end - activeNotifications.append(notification) - } - } - } - - /// Handles a new notification being added - func handleNewNotification(_ notification: CENotification) { - let operation = { - self.insertNotification(notification) - self.hiddenNotificationIds.remove(notification.id) - if !self.isPresented && !notification.isSticky { - self.startHideTimer(for: notification) - } - } - - if #available(macOS 26, *) { - withAnimation(.easeInOut(duration: 0.3), operation) { - self.updateToolbarItem() - } - } else { - withAnimation(.easeInOut(duration: 0.3), operation) - } - } - - /// Dismisses a specific notification - func dismissNotification(_ notification: CENotification, disableAnimation: Bool = false) { - // Clean up timers - timers[notification.id]?.invalidate() - timers[notification.id] = nil - hiddenNotificationIds.remove(notification.id) - - // Mark as being dismissed for animation - if let index = activeNotifications.firstIndex(where: { $0.id == notification.id }) { - if disableAnimation { - self.activeNotifications.removeAll(where: { $0.id == notification.id }) - NotificationManager.shared.markAsRead(notification) - NotificationManager.shared.dismissNotification(notification) - return - } - - var dismissingNotification = activeNotifications[index] - dismissingNotification.isBeingDismissed = true - activeNotifications[index] = dismissingNotification - - // Wait for fade animation before removing - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - withAnimation(.easeOut(duration: 0.2)) { - self.activeNotifications.removeAll(where: { $0.id == notification.id }) - if self.activeNotifications.isEmpty && self.isPresented { - self.isPresented = false - } - } - - NotificationManager.shared.markAsRead(notification) - NotificationManager.shared.dismissNotification(notification) - } - } - } - - func updateToolbarItem() { - if #available(macOS 15.0, *) { - self.workspace?.windowControllers.forEach { controller in - guard let toolbar = controller.window?.toolbar else { - return - } - let shouldShow = !self.visibleNotifications.isEmpty || NotificationManager.shared.unreadCount > 0 - if shouldShow && toolbar.items.filter({ $0.itemIdentifier == .notificationItem }).first == nil { - guard let activityItemIdx = toolbar.items - .firstIndex(where: { $0.itemIdentifier == .activityViewer }) else { - return - } - toolbar.insertItem(withItemIdentifier: .space, at: activityItemIdx + 1) - toolbar.insertItem(withItemIdentifier: .notificationItem, at: activityItemIdx + 2) - } - - if !shouldShow, let index = toolbar.items - .firstIndex(where: { $0.itemIdentifier == .notificationItem }) { - toolbar.removeItem(at: index) - toolbar.removeItem(at: index) - } - } - } - } - - init() { - // Observe new notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(handleNewNotificationAdded(_:)), - name: .init("NewNotificationAdded"), - object: nil - ) - - // Observe notification dismissals - NotificationCenter.default.addObserver( - self, - selector: #selector(handleNotificationRemoved(_:)), - name: .init("NotificationDismissed"), - object: nil - ) - - // Load initial notifications from NotificationManager - notificationManager.notifications.forEach { notification in - handleNewNotification(notification) - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc - private func handleNewNotificationAdded(_ notification: Notification) { - guard let ceNotification = notification.object as? CENotification else { return } - handleNewNotification(ceNotification) - } - - @objc - private func handleNotificationRemoved(_ notification: Notification) { - guard let ceNotification = notification.object as? CENotification else { return } - - let operation: () -> Void = { - self.activeNotifications.removeAll(where: { $0.id == ceNotification.id }) - - // If this was the last notification and they were manually shown, hide the panel - if self.activeNotifications.isEmpty && self.isPresented { - self.isPresented = false - } - } - - // Just remove from active notifications without triggering global state changes - if #available(macOS 26, *) { - withAnimation(.easeOut(duration: 0.2), operation) { - self.updateToolbarItem() - } - } else { - withAnimation(.easeOut(duration: 0.2), operation) - } - } -} diff --git a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift deleted file mode 100644 index 0a696432c1..0000000000 --- a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// OpenQuicklyPreviewView.swift -// CodeEditModules/QuickOpen -// -// Created by Pavel Kasila on 20.03.22. -// - -import SwiftUI - -struct OpenQuicklyPreviewView: View { - - private let queue = DispatchQueue(label: "app.codeedit.CodeEdit.quickOpen.preview") - private let item: CEWorkspaceFile - - @StateObject var editorInstance: EditorInstance - @StateObject var document: CodeFileDocument - - @StateObject var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() - - init(item: CEWorkspaceFile) { - self.item = item - let doc = try? CodeFileDocument( - for: item.url, - withContentsOf: item.url, - ofType: item.contentType?.identifier ?? "public.source-code" - ) - self._editorInstance = .init(wrappedValue: EditorInstance(workspace: nil, file: item)) - self._document = .init(wrappedValue: doc ?? .init()) - } - - var body: some View { - if let utType = document.utType, utType.conforms(to: .text) { - CodeFileView(editorInstance: editorInstance, codeFile: document, isEditable: false) - .environmentObject(undoRegistration) - } else { - NonTextFileView(fileDocument: document) - } - } -} diff --git a/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift b/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift deleted file mode 100644 index 828866e391..0000000000 --- a/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Collection+FuzzySearch.swift -// CodeEdit -// -// Created by Tommy Ludwig on 03.02.24. -// - -import Foundation -import CollectionConcurrencyKit - -extension Collection where Iterator.Element: FuzzySearchable { - /// Asynchronously performs a fuzzy search on a collection of elements conforming to FuzzySearchable. - /// - /// - Parameter query: The query string to match against the elements. - /// - /// - Returns: An array of tuples containing FuzzySearchMatchResult and the corresponding element. - /// - /// - Note: Because this is an extension on Collection and not only array, - /// you can also use this on sets. - func fuzzySearch(query: String) async -> [(result: FuzzySearchMatchResult, item: Iterator.Element)] { - return await concurrentMap { - (result: $0.fuzzyMatch(query: query), item: $0) - }.filter { - $0.result.weight > 0 - }.sorted { - $0.result.weight > $1.result.weight - } - } -} diff --git a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchModels.swift b/CodeEdit/Features/Search/FuzzySearch/FuzzySearchModels.swift deleted file mode 100644 index 9f5511a701..0000000000 --- a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchModels.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// FuzzySearchModels.swift -// CodeEdit -// -// Created by Tommy Ludwig on 03.02.24. -// - -import Foundation - -/// FuzzySearchCharacters is used to normalise strings -struct FuzzySearchCharacter { - let content: String - // normalised content is referring to a string that is case- and accent-insensitive - let normalisedContent: String -} - -/// FuzzySearchString is just made up by multiple characters, similar to a string, but also with normalised characters -struct FuzzySearchString { - var characters: [FuzzySearchCharacter] -} - -/// FuzzySearchMatchResult represents an object that has undergone a fuzzy search using the fuzzyMatch function. -struct FuzzySearchMatchResult { - let weight: Int - let matchedParts: [NSRange] -} diff --git a/CodeEdit/Features/Settings/Models/AppSettings.swift b/CodeEdit/Features/Settings/Models/AppSettings.swift deleted file mode 100644 index d7a115df37..0000000000 --- a/CodeEdit/Features/Settings/Models/AppSettings.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AppSettings.swift -// CodeEdit -// -// Created by Wouter Hennen on 12/04/2023. -// - -import Foundation -import SwiftUI - -@propertyWrapper -struct AppSettings: DynamicProperty where T: Equatable { - - var settings: Environment - - let keyPath: WritableKeyPath - - init(_ keyPath: WritableKeyPath) { - self.keyPath = keyPath - let settingsKeyPath = (\EnvironmentValues.settings).appending(path: keyPath) - self.settings = Environment(settingsKeyPath) - } - - var wrappedValue: T { - get { - Settings.shared.preferences[keyPath: keyPath] - } - nonmutating set { - Settings.shared.preferences[keyPath: keyPath] = newValue - } - } - - var projectedValue: Binding { - Binding { - Settings.shared.preferences[keyPath: keyPath] - } set: { - Settings.shared.preferences[keyPath: keyPath] = $0 - } - } -} - -struct SettingsDataEnvironmentKey: EnvironmentKey { - static var defaultValue: SettingsData = .init() -} - -extension EnvironmentValues { - var settings: SettingsDataEnvironmentKey.Value { - get { self[SettingsDataEnvironmentKey.self] } - set { self[SettingsDataEnvironmentKey.self] = newValue } - } -} diff --git a/CodeEdit/Features/Settings/Models/Settings.swift b/CodeEdit/Features/Settings/Models/Settings.swift deleted file mode 100644 index 0d638c0ec0..0000000000 --- a/CodeEdit/Features/Settings/Models/Settings.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// Settings.swift -// CodeEditModules/Settings -// -// Created by Lukas Pistrol on 01.04.22. -// - -import Foundation -import SwiftUI -import Combine - -/// The Preferences View Model. Accessible via the singleton "``SettingsModel/shared``". -/// -/// **Usage:** -/// ```swift -/// @StateObject -/// private var prefs: SettingsModel = .shared -/// ``` -final class Settings: ObservableObject { - - /// The publicly available singleton instance of ``SettingsModel`` - static let shared: Settings = .init() - - private var storeTask: AnyCancellable! - - private init() { - self.preferences = .init() - self.preferences = loadSettings() - - self.storeTask = self.$preferences.throttle(for: 2, scheduler: RunLoop.main, latest: true).sink { - try? self.savePreferences($0) - } - } - - static subscript(_ path: WritableKeyPath, suite: Settings = .shared) -> T { - get { - suite.preferences[keyPath: path] - } - set { - suite.preferences[keyPath: path] = newValue - } - } - - /// Published instance of the ``Settings`` model. - /// - /// Changes are saved automatically. - @Published var preferences: SettingsData - - /// Load and construct ``Settings`` model from - /// `~/Library/Application Support/CodeEdit/settings.json` - private func loadSettings() -> SettingsData { - if !filemanager.fileExists(atPath: settingsURL.path) { - try? filemanager.createDirectory(at: baseURL, withIntermediateDirectories: false) - return .init() - } - - guard let json = try? Data(contentsOf: settingsURL), - let prefs = try? JSONDecoder().decode(SettingsData.self, from: json) - else { - return .init() - } - return prefs - } - - /// Save``Settings`` model to - /// `~/Library/Application Support/CodeEdit/settings.json` - private func savePreferences(_ data: SettingsData) throws { - let data = try JSONEncoder().encode(data) - let json = try JSONSerialization.jsonObject(with: data) - let prettyJSON = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) - try prettyJSON.write(to: settingsURL, options: .atomic) - } - - /// Default instance of the `FileManager` - private let filemanager = FileManager.default - - /// The base URL of settings. - /// - /// Points to `~/Library/Application Support/CodeEdit/` - internal var baseURL: URL { - filemanager - .homeDirectoryForCurrentUser - .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) - } - - /// The URL of the `settings.json` settings file. - /// - /// Points to `~/Library/Application Support/CodeEdit/settings.json` - private var settingsURL: URL { - baseURL - .appending(path: "settings") - .appendingPathExtension("json") - } -} diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift deleted file mode 100644 index cd860c7e43..0000000000 --- a/CodeEdit/Features/Settings/Models/SettingsData.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// Settings.swift -// CodeEditModules/Settings -// -// Created by Lukas Pistrol on 01.04.22. -// - -import SwiftUI -import Foundation - -/// # Settings -/// -/// The model structure of settings for `CodeEdit` -/// -/// A `JSON` representation is persisted in `~/Library/Application Support/CodeEdit/preference.json`. -/// - Attention: Don't use `UserDefaults` for persisting user accessible settings. -/// If a further setting is needed, extend the struct like ``GeneralSettings``, -/// ``ThemeSettings``, or ``TerminalSettings`` does. -/// -/// - Note: Also make sure to implement the ``init(from:)`` initializer, decoding -/// all properties with -/// [`decodeIfPresent`](https://developer.apple.com/documentation/swift/keyeddecodingcontainer/2921389-decodeifpresent) -/// and providing a default value. Otherwise all settings get overridden. -struct SettingsData: Codable, Hashable { - - /// The general global settings - var general: GeneralSettings = .init() - - /// The global settings for accounts - var accounts: AccountsSettings = .init() - - /// The global settings for themes - var navigation: NavigationSettings = .init() - - /// The global settings for themes - var theme: ThemeSettings = .init() - - /// The global settings for text editing - var textEditing: TextEditingSettings = .init() - - /// The global settings for the terminal emulator - var terminal: TerminalSettings = .init() - - /// The global settings for source control - var sourceControl: SourceControlSettings = .init() - - /// The global settings for keybindings - var keybindings: KeybindingsSettings = .init() - - /// Search Settings - var search: SearchSettings = .init() - - /// Language Server Settings - var languageServers: LanguageServerSettings = .init() - - /// Developer settings for CodeEdit developers - var developerSettings: DeveloperSettings = .init() - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.general = try container.decodeIfPresent(GeneralSettings.self, forKey: .general) ?? .init() - self.accounts = try container.decodeIfPresent(AccountsSettings.self, forKey: .accounts) ?? .init() - self.navigation = try container.decodeIfPresent(NavigationSettings.self, forKey: .navigation) ?? .init() - self.theme = try container.decodeIfPresent(ThemeSettings.self, forKey: .theme) ?? .init() - self.terminal = try container.decodeIfPresent(TerminalSettings.self, forKey: .terminal) ?? .init() - self.textEditing = try container.decodeIfPresent(TextEditingSettings.self, forKey: .textEditing) ?? .init() - self.search = try container.decodeIfPresent(SearchSettings.self, forKey: .search) ?? .init() - self.sourceControl = try container.decodeIfPresent( - SourceControlSettings.self, - forKey: .sourceControl - ) ?? .init() - self.keybindings = try container.decodeIfPresent( - KeybindingsSettings.self, - forKey: .keybindings - ) ?? .init() - self.languageServers = try container.decodeIfPresent( - LanguageServerSettings.self, forKey: .languageServers - ) ?? .init() - self.developerSettings = try container.decodeIfPresent( - DeveloperSettings.self, forKey: .developerSettings - ) ?? .init() - } - - // swiftlint:disable cyclomatic_complexity - func propertiesOf(_ name: SettingsPage.Name) -> [SettingsPage] { - var settings: [SettingsPage] = [] - - switch name { - case .general: - general.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .accounts: - accounts.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .navigation: - navigation.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .theme: - theme.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .textEditing: - textEditing.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .terminal: - terminal.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .search: - search.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .sourceControl: - sourceControl.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .location: - LocationsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .languageServers: - LanguageServerSettings().searchKeys.forEach { - settings.append(.init(name, isSetting: true, settingName: $0)) - } - case .developer: - developerSettings.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .behavior: return [.init(name, settingName: "Error")] - case .components: return [.init(name, settingName: "Error")] - case .keybindings: return [.init(name, settingName: "Error")] - case .advanced: return [.init(name, settingName: "Error")] - } - - return settings - } - // swiftlint:enable cyclomatic_complexity -} diff --git a/CodeEdit/Features/Settings/Models/SettingsInjector.swift b/CodeEdit/Features/Settings/Models/SettingsInjector.swift deleted file mode 100644 index 301991273d..0000000000 --- a/CodeEdit/Features/Settings/Models/SettingsInjector.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SettingsInjector.swift -// CodeEdit -// -// Created by Wouter Hennen on 28/04/2023. -// - -import SwiftUI - -struct SettingsInjector: View { - - @ObservedObject var settings = Settings.shared - - @ViewBuilder var content: Content - - var body: some View { - content - .environment(\.settings, settings.preferences) - } -} diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/Models/AccountsSettings.swift b/CodeEdit/Features/Settings/Pages/AccountsSettings/Models/AccountsSettings.swift deleted file mode 100644 index ef8922187a..0000000000 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/Models/AccountsSettings.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// AccountsPreferences.swift -// CodeEditModules/Settings -// -// Created by Nanashi Li on 2022/04/08. -// - -import Foundation - -extension SettingsData { - - /// The global settings for source control accounts - struct AccountsSettings: Codable, Hashable, SearchableSettingsPage { - /// The list of git accounts the user has saved - var sourceControlAccounts: GitAccounts = .init() - - /// The search keys - var searchKeys: [String] { - [ - "Accounts", - "Delete Account...", - "Add Account..." - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.sourceControlAccounts = try container.decodeIfPresent( - GitAccounts.self, - forKey: .sourceControlAccounts - ) ?? .init() - } - } - - struct GitAccounts: Codable, Hashable { - /// This id will store the account name as the identifiable - var gitAccounts: [SourceControlAccount] = [] - - var sshKey: String = "" - /// Default initializer - init() {} - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.gitAccounts = try container.decodeIfPresent([SourceControlAccount].self, forKey: .gitAccounts) ?? [] - self.sshKey = try container.decodeIfPresent(String.self, forKey: .sshKey) ?? "" - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift b/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift deleted file mode 100644 index f52f075385..0000000000 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// DeveloperSettings.swift -// CodeEdit -// -// Created by Abe Malla on 5/15/24. -// - -import Foundation - -extension SettingsData { - struct DeveloperSettings: Codable, Hashable, SearchableSettingsPage { - - /// The search keys - var searchKeys: [String] { - [ - "Developer", - "Language Server Protocol", - "LSP Binaries", - "Show Internal Development Inspector" - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// A dictionary that stores a file type and a path to an LSP binary - var lspBinaries: [String: String] = [:] - - /// Toggle for showing the internal development inspector - var showInternalDevelopmentInspector: Bool = false - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.lspBinaries = try container.decodeIfPresent( - [String: String].self, - forKey: .lspBinaries - ) ?? [:] - - self.showInternalDevelopmentInspector = try container.decodeIfPresent( - Bool.self, - forKey: .showInternalDevelopmentInspector - ) ?? false - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift deleted file mode 100644 index 9e65691a98..0000000000 --- a/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// LanguageServerSettings.swift -// CodeEdit -// -// Created by Abe Malla on 2/2/25. -// - -import Foundation - -extension SettingsData { - struct LanguageServerSettings: Codable, Hashable, SearchableSettingsPage { - - /// The search keys - var searchKeys: [String] { - [ - "Language Servers", - "LSP Binaries", - "Linters", - "Formatters", - "Debug Protocol", - "DAP", - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// Stores the currently installed language servers. The key is the name of the language server. - var installedLanguageServers: [String: InstalledLanguageServer] = [:] - - /// Default initializer - init() { - self.installedLanguageServers = [:] - } - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.installedLanguageServers = try container.decodeIfPresent( - [String: InstalledLanguageServer].self, - forKey: .installedLanguageServers - ) ?? [:] - } - } - - struct InstalledLanguageServer: Codable, Hashable { - let packageName: String - var isEnabled: Bool - let version: String - } -} diff --git a/CodeEdit/Features/Settings/Pages/GeneralSettings/Models/GeneralSettings.swift b/CodeEdit/Features/Settings/Pages/GeneralSettings/Models/GeneralSettings.swift deleted file mode 100644 index 12e2375505..0000000000 --- a/CodeEdit/Features/Settings/Pages/GeneralSettings/Models/GeneralSettings.swift +++ /dev/null @@ -1,318 +0,0 @@ -// -// GeneralSettings.swift -// CodeEditModules/Settings -// -// Created by Nanashi Li on 2022/04/08. -// - -import SwiftUI - -extension SettingsData { - - /// The general global setting - struct GeneralSettings: Codable, Hashable, SearchableSettingsPage { - - /// The appearance of the app - var appAppearance: Appearances = .system - - /// The show issues behavior of the app - var showIssues: Issues = .inline - - /// The show live issues behavior of the app - var showLiveIssues: Bool = true - - /// The search keys - var searchKeys: [String] { - [ - "Appearance", - "File Icon Style", - "Tab Bar Style", - "Show Jump Bar", - "Dim editors without focus", - "Navigator Tab Bar Position", - "Inspector Tab Bar Position", - "Show Issues", - "Show Live Issues", - "Automatically save change to disk", - "Automatically reveal in project navigator", - "Reopen Behavior", - "After the last window is closed", - "File Extensions", - "Project Navigator Size", - "Find Navigator Detail", - "Issue Navigator Detail", - "Show “Open With CodeEdit“ option in Finder", - "'codeedit' Shell command", - "Dialog Warnings", - "Check for updates", - "Automatically check for app updates", - "Include pre-release versions" - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// Show editor jump bar - var showEditorJumpBar: Bool = true - - /// Dims editors without focus - var dimEditorsWithoutFocus: Bool = false - - /// The show file extensions behavior of the app - var fileExtensionsVisibility: FileExtensionsVisibility = .showAll - - /// The file extensions collection to display - var shownFileExtensions: FileExtensions = .default - - /// The file extensions collection to hide - var hiddenFileExtensions: FileExtensions = .default - - /// The style for file icons - var fileIconStyle: FileIconStyle = .color - - /// The position for the navigator sidebar tab bar - var navigatorTabBarPosition: SidebarTabBarPosition = .top - - /// The position for the inspector sidebar tab bar - var inspectorTabBarPosition: SidebarTabBarPosition = .top - - /// The reopen behavior of the app - var reopenBehavior: ReopenBehavior = .welcome - - /// Decides what the app does after a workspace is closed - var reopenWindowAfterClose: ReopenWindowBehavior = .doNothing - - /// The size of the project navigator - var projectNavigatorSize: ProjectNavigatorSize = .medium - - /// The Find Navigator Detail line limit - var findNavigatorDetail: NavigatorDetail = .upTo3 - - /// The Issue Navigator Detail line limit - var issueNavigatorDetail: NavigatorDetail = .upTo3 - - /// The reveal file in navigator when focus changes behavior of the app. - var revealFileOnFocusChange: Bool = false - - /// Auto save behavior toggle - var isAutoSaveOn: Bool = true - - /// Default initializer - init() {} - - // swiftlint:disable function_body_length - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.appAppearance = try container.decodeIfPresent( - Appearances.self, - forKey: .appAppearance - ) ?? .system - self.showIssues = try container.decodeIfPresent( - Issues.self, - forKey: .showIssues - ) ?? .inline - self.showLiveIssues = try container.decodeIfPresent( - Bool.self, - forKey: .showLiveIssues - ) ?? true - self.showEditorJumpBar = try container.decodeIfPresent( - Bool.self, - forKey: .showEditorJumpBar - ) ?? true - self.dimEditorsWithoutFocus = try container.decodeIfPresent( - Bool.self, - forKey: .dimEditorsWithoutFocus - ) ?? false - self.fileExtensionsVisibility = try container.decodeIfPresent( - FileExtensionsVisibility.self, - forKey: .fileExtensionsVisibility - ) ?? .showAll - self.shownFileExtensions = try container.decodeIfPresent( - FileExtensions.self, - forKey: .shownFileExtensions - ) ?? .default - self.hiddenFileExtensions = try container.decodeIfPresent( - FileExtensions.self, - forKey: .hiddenFileExtensions - ) ?? .default - self.fileIconStyle = try container.decodeIfPresent( - FileIconStyle.self, - forKey: .fileIconStyle - ) ?? .color - self.navigatorTabBarPosition = try container.decodeIfPresent( - SidebarTabBarPosition.self, - forKey: .navigatorTabBarPosition - ) ?? .top - self.inspectorTabBarPosition = try container.decodeIfPresent( - SidebarTabBarPosition.self, - forKey: .inspectorTabBarPosition - ) ?? .top - self.reopenBehavior = try container.decodeIfPresent( - ReopenBehavior.self, - forKey: .reopenBehavior - ) ?? .welcome - self.reopenWindowAfterClose = try container.decodeIfPresent( - ReopenWindowBehavior.self, - forKey: .reopenWindowAfterClose - ) ?? .doNothing - self.projectNavigatorSize = try container.decodeIfPresent( - ProjectNavigatorSize.self, - forKey: .projectNavigatorSize - ) ?? .medium - self.findNavigatorDetail = try container.decodeIfPresent( - NavigatorDetail.self, - forKey: .findNavigatorDetail - ) ?? .upTo3 - self.issueNavigatorDetail = try container.decodeIfPresent( - NavigatorDetail.self, - forKey: .issueNavigatorDetail - ) ?? .upTo3 - self.revealFileOnFocusChange = try container.decodeIfPresent( - Bool.self, - forKey: .revealFileOnFocusChange - ) ?? false - self.isAutoSaveOn = try container.decodeIfPresent( - Bool.self, - forKey: .isAutoSaveOn - ) ?? true - } - // swiftlint:enable function_body_length - } - - /// The appearance of the app - /// - **system**: uses the system appearance - /// - **dark**: always uses dark appearance - /// - **light**: always uses light appearance - enum Appearances: String, Codable { - case system - case light - case dark - - /// Applies the selected appearance - func applyAppearance() { - switch self { - case .system: - NSApp.appearance = nil - - case .dark: - NSApp.appearance = .init(named: .darkAqua) - - case .light: - NSApp.appearance = .init(named: .aqua) - } - } - } - - /// The style for issues display - /// - **inline**: Issues show inline - /// - **minimized** Issues show minimized - enum Issues: String, Codable { - case inline - case minimized - } - - /// The style for file extensions visibility - /// - **hideAll**: File extensions are hidden - /// - **showAll** File extensions are visible - /// - **showOnly** Specific file extensions are visible - /// - **hideOnly** Specific file extensions are hidden - enum FileExtensionsVisibility: Codable, Hashable { - case hideAll - case showAll - case showOnly - case hideOnly - } - - /// The collection of file extensions used by - /// ``FileExtensionsVisibility/showOnly`` or ``FileExtensionsVisibility/hideOnly`` preference - struct FileExtensions: Codable, Hashable { - var extensions: [String] - - var string: String { - get { - extensions.joined(separator: ", ") - } - set { - extensions = newValue - .components(separatedBy: ",") - .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) - .filter({ !$0.isEmpty || string.count < newValue.count }) - } - } - - static var `default` = FileExtensions(extensions: [ - "c", "cc", "cpp", "h", "hpp", "m", "mm", "gif", - "icns", "jpeg", "jpg", "png", "tiff", "swift" - ]) - } - /// The style for file icons - /// - **color**: File icons appear in their default colors - /// - **monochrome**: File icons appear monochromatic - enum FileIconStyle: String, Codable { - case color - case monochrome - } - - /// The position for a sidebar tab bar - /// - **top**: Tab bar is positioned at the top of the sidebar - /// - **side**: Tab bar is positioned to the side of the sidebar - enum SidebarTabBarPosition: String, Codable { - case top, side - } - - /// The reopen behavior of the app - /// - **welcome**: On restart the app will show the welcome screen - /// - **openPanel**: On restart the app will show an open panel - /// - **newDocument**: On restart a new empty document will be created - enum ReopenBehavior: String, Codable { - case welcome - case openPanel - case newDocument - } - - enum ReopenWindowBehavior: String, Codable { - case showWelcomeWindow - case doNothing - case quit - } - - enum ProjectNavigatorSize: String, Codable { - case small - case medium - case large - - /// Returns the row height depending on the `projectNavigatorSize` in `Settings`. - /// - /// * `small`: 20 - /// * `medium`: 22 - /// * `large`: 24 - var rowHeight: Double { - switch self { - case .small: return 20 - case .medium: return 22 - case .large: return 24 - } - } - } - - /// The Navigation Detail behavior of the app - /// - Use **rawValue** to set lineLimit - enum NavigatorDetail: Int, Codable, CaseIterable { - case upTo1 = 1 - case upTo2 = 2 - case upTo3 = 3 - case upTo4 = 4 - case upTo5 = 5 - case upTo10 = 10 - case upTo30 = 30 - - var label: String { - switch self { - case .upTo1: - return "One Line" - default: - return "Up to \(self.rawValue) lines" - } - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/Keybindings/Models/KeybindingsSettings.swift b/CodeEdit/Features/Settings/Pages/Keybindings/Models/KeybindingsSettings.swift deleted file mode 100644 index 883ac3a175..0000000000 --- a/CodeEdit/Features/Settings/Pages/Keybindings/Models/KeybindingsSettings.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// KeybindingsPreferences.swift -// CodeEditModules/Settings -// -// Created by Alex on 18.05.2022. -// - -import Foundation - -extension SettingsData { - - /// The global settings for text editing - struct KeybindingsSettings: Codable, Hashable { - - /// An integer indicating how many spaces a `tab` will generate - var keybindings: [String: KeyboardShortcutWrapper] = .init() - - /// Default initializer - init() { - self.keybindings = KeybindingManager.shared.keyboardShortcuts - } - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.keybindings = try container.decodeIfPresent( - [String: KeyboardShortcutWrapper].self, - forKey: .keybindings - ) ?? .init() - appendNew() - } - - /// Adds new keybindings if they were added to default_keybindings.json. - /// To ensure users will get new keybindings with new app version releases - private mutating func appendNew() { - let newKeybindings = KeybindingManager.shared - .keyboardShortcuts.filter { !keybindings.keys.contains($0.key) } - for keybinding in newKeybindings { - self.keybindings[keybinding.key] = KeybindingManager.shared.named(with: keybinding.key) - } - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/LocationsSettings/Models/LocationsSettings.swift b/CodeEdit/Features/Settings/Pages/LocationsSettings/Models/LocationsSettings.swift deleted file mode 100644 index 9481b1f019..0000000000 --- a/CodeEdit/Features/Settings/Pages/LocationsSettings/Models/LocationsSettings.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// LocationsSettings.swift -// CodeEdit -// -// Created by Raymond Vleeshouwer on 24/06/23. -// - -import Foundation - -extension SettingsData { - - struct LocationsSettings: SearchableSettingsPage { - - /// The search keys - var searchKeys: [String] { - [ - "Settings Location", - "Themes Location", - "Extensions Location" - ] - .map { NSLocalizedString($0, comment: "") } - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/NavigationSettings/Models/NavigationSettings.swift b/CodeEdit/Features/Settings/Pages/NavigationSettings/Models/NavigationSettings.swift deleted file mode 100644 index fa95f98d37..0000000000 --- a/CodeEdit/Features/Settings/Pages/NavigationSettings/Models/NavigationSettings.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// NavigationSettings.swift -// CodeEdit -// -// Created by Austin Condiff on 3/4/24. -// - -import Foundation - -extension SettingsData { - - /// The global settings for the terminal emulator - struct NavigationSettings: Codable, Hashable, SearchableSettingsPage { - - /// The search keys - var searchKeys: [String] { - [ - "Navigation Style", - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// Navigation style used - var navigationStyle: NavigationStyle = .openInTabs - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.navigationStyle = try container.decodeIfPresent( - NavigationStyle.self, forKey: .navigationStyle - ) ?? .openInTabs - } - } - - enum NavigationStyle: String, Codable, Hashable { - case openInTabs - case openInPlace - } -} diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettings.swift b/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettings.swift deleted file mode 100644 index 6510d418c0..0000000000 --- a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettings.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// SearchSettings.swift -// CodeEdit -// -// Created by Esteban on 12/10/23. -// - -import Foundation - -extension SettingsData { - struct SearchSettings: Codable, Hashable, SearchableSettingsPage { - - /// The search keys - var searchKeys: [String] { - [ - "Ignore Glob Patterns", - "Ignore Patterns" - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// List of Glob Patterns that determine which files or directories to ignore - var ignoreGlobPatterns: [GlobPattern] = .init() - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.ignoreGlobPatterns = try container.decodeIfPresent( - [GlobPattern].self, - forKey: .ignoreGlobPatterns - ) ?? [] - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/TerminalSettings/Models/TerminalSettings.swift b/CodeEdit/Features/Settings/Pages/TerminalSettings/Models/TerminalSettings.swift deleted file mode 100644 index 38f18bb316..0000000000 --- a/CodeEdit/Features/Settings/Pages/TerminalSettings/Models/TerminalSettings.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// TerminalPreferences.swift -// CodeEditModules/Settings -// -// Created by Nanashi Li on 2022/04/08. -// - -import AppKit -import Foundation - -extension SettingsData { - - /// The global settings for the terminal emulator - struct TerminalSettings: Codable, Hashable, SearchableSettingsPage { - - /// The search keys - var searchKeys: [String] { - [ - "Shell", - "Use \"Option\" key as \"Meta\"", - "Use text editor font", - "Font", - "Font Size", - "Terminal Cursor Style", - "Blink Cursor" - ] - .map { NSLocalizedString($0, comment: "") } - } - - /// If true terminal will use editor theme. - var useEditorTheme: Bool = true - - /// If true terminal appearance will always be `dark`. Otherwise it adapts to the system setting. - var darkAppearance: Bool = false - - /// If true, the terminal uses the background color of the theme, otherwise it is clear - var useThemeBackground: Bool = true - - /// If true, the terminal treats the `Option` key as the `Meta` key - var optionAsMeta: Bool = false - - /// The selected shell to use. - var shell: TerminalShell = .system - - /// The font to use in terminal. - var font: TerminalFont = .init() - - // The cursor style to use in terminal - var cursorStyle: TerminalCursorStyle = .block - - // Toggle for blinking cursor or not - var cursorBlink: Bool = false - - // Use font settings from Text Editing - var useTextEditorFont: Bool = true - - /// If `true`, use injection scripts for terminal features like automatic tab title. - var useShellIntegration: Bool = true - - /// If `true`, use a login shell. - var useLoginShell: Bool = true - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.darkAppearance = try container.decodeIfPresent(Bool.self, forKey: .darkAppearance) ?? false - self.optionAsMeta = try container.decodeIfPresent(Bool.self, forKey: .optionAsMeta) ?? false - self.shell = try container.decodeIfPresent(TerminalShell.self, forKey: .shell) ?? .system - self.font = try container.decodeIfPresent(TerminalFont.self, forKey: .font) ?? .init() - self.cursorStyle = try container.decodeIfPresent( - TerminalCursorStyle.self, - forKey: .cursorStyle - ) ?? .block - self.cursorBlink = try container.decodeIfPresent(Bool.self, forKey: .cursorBlink) ?? false - self.useTextEditorFont = try container.decodeIfPresent(Bool.self, forKey: .useTextEditorFont) ?? true - self.useShellIntegration = try container.decodeIfPresent(Bool.self, forKey: .useShellIntegration) ?? true - self.useLoginShell = try container.decodeIfPresent(Bool.self, forKey: .useLoginShell) ?? true - } - } - - /// The shell options. - /// - **bash**: uses the default bash shell - /// - **zsh**: uses the ZSH shell - /// - **system**: uses the system default shell (most likely ZSH) - enum TerminalShell: String, Codable, Hashable { - case bash - case zsh - case system - } - - enum TerminalCursorStyle: String, Codable, Hashable { - case block - case underline - case bar - } - - struct TerminalFont: Codable, Hashable { - /// The font size for the custom font - var size: Double = 12 - - /// The name of the custom font - var name: String = "SF Mono" - - /// The weight of the custom font - var weight: NSFont.Weight = .medium - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.size = try container.decodeIfPresent(Double.self, forKey: .size) ?? size - self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? name - self.weight = try container.decodeIfPresent(NSFont.Weight.self, forKey: .weight) ?? weight - } - - /// Returns an NSFont representation of the current configuration. - /// - /// Returns the custom font, if enabled and able to be instantiated. - /// Otherwise returns a default system font monospaced. - var current: NSFont { - let customFont = NSFont(name: name, size: size)?.withWeight(weight: weight) - return customFont ?? NSFont.monospacedSystemFont(ofSize: size, weight: .medium) - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift deleted file mode 100644 index b5b5abb5a4..0000000000 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ /dev/null @@ -1,340 +0,0 @@ -// -// TextEditingPreferences.swift -// CodeEditModules/Settings -// -// Created by Nanashi Li on 2022/04/08. -// - -import AppKit -import Foundation - -extension SettingsData { - - /// The global settings for text editing - struct TextEditingSettings: Codable, Hashable, SearchableSettingsPage { - - var searchKeys: [String] { - var keys = [ - "Prefer Indent Using", - "Tab Width", - "Wrap lines to editor width", - "Editor Overscroll", - "Font", - "Font Size", - "Font Weight", - "Line Height", - "Letter Spacing", - "Autocomplete braces", - "Enable type-over completion", - "Bracket Pair Emphasis", - "Bracket Pair Highlight", - "Show Gutter", - "Show Minimap", - "Reformat at Column", - "Show Reformatting Guide", - "Invisibles", - "Warning Characters" - ] - if #available(macOS 14.0, *) { - keys.append("System Cursor") - } - return keys.map { NSLocalizedString($0, comment: "") } - } - - /// An integer indicating how many spaces a `tab` will appear as visually. - var defaultTabWidth: Int = 4 - - /// The behavior of a `tab` keypress. If `.tab`, will insert a tab character. If `.spaces` will insert - /// `.spaceCount` spaces instead. - var indentOption: IndentOption = IndentOption(indentType: .spaces, spaceCount: 4) - - /// The font to use in editor. - var font: EditorFont = .init() - - /// A flag indicating whether type-over completion is enabled - var enableTypeOverCompletion: Bool = true - - /// A flag indicating whether braces are automatically completed - var autocompleteBraces: Bool = true - - /// A flag indicating whether to wrap lines to editor width - var wrapLinesToEditorWidth: Bool = true - - /// The percentage of overscroll to apply to the text view - var overscroll: OverscrollOption = .medium - - /// A multiplier for setting the line height. Defaults to `1.2` - var lineHeightMultiple: Double = 1.2 - - /// A multiplier for setting the letter spacing, `1` being no spacing and - /// `2` is one character of spacing between letters, defaults to `1`. - var letterSpacing: Double = 1.0 - - /// The behavior of bracket pair highlights. - var bracketEmphasis: BracketPairEmphasis = BracketPairEmphasis() - - /// Use the system cursor for the source editor. - var useSystemCursor: Bool = true - - /// Toggle the gutter in the editor. - var showGutter: Bool = true - - /// Toggle the minimap in the editor. - var showMinimap: Bool = true - - /// Toggle the code folding ribbon. - var showFoldingRibbon: Bool = true - - /// The column at which to reformat text - var reformatAtColumn: Int = 80 - - /// Show the reformatting guide in the editor - var showReformattingGuide: Bool = false - - var invisibleCharacters: InvisibleCharactersConfig = .default - - /// Map of unicode character codes to a note about them - var warningCharacters: WarningCharacters = .default - - /// Default initializer - init() { - self.populateCommands() - } - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { // swiftlint:disable:this function_body_length - let container = try decoder.container(keyedBy: CodingKeys.self) - self.defaultTabWidth = try container.decodeIfPresent(Int.self, forKey: .defaultTabWidth) ?? 4 - self.indentOption = try container.decodeIfPresent( - IndentOption.self, - forKey: .indentOption - ) ?? IndentOption(indentType: .spaces, spaceCount: 4) - self.font = try container.decodeIfPresent(EditorFont.self, forKey: .font) ?? .init() - self.enableTypeOverCompletion = try container.decodeIfPresent( - Bool.self, - forKey: .enableTypeOverCompletion - ) ?? true - self.autocompleteBraces = try container.decodeIfPresent( - Bool.self, - forKey: .autocompleteBraces - ) ?? true - self.wrapLinesToEditorWidth = try container.decodeIfPresent( - Bool.self, - forKey: .wrapLinesToEditorWidth - ) ?? true - self.overscroll = try container.decodeIfPresent( - OverscrollOption.self, - forKey: .overscroll - ) ?? .medium - self.lineHeightMultiple = try container.decodeIfPresent( - Double.self, - forKey: .lineHeightMultiple - ) ?? 1.2 - self.letterSpacing = try container.decodeIfPresent( - Double.self, - forKey: .letterSpacing - ) ?? 1 - self.bracketEmphasis = try container.decodeIfPresent( - BracketPairEmphasis.self, - forKey: .bracketEmphasis - ) ?? BracketPairEmphasis() - if #available(macOS 14, *) { - self.useSystemCursor = try container.decodeIfPresent(Bool.self, forKey: .useSystemCursor) ?? true - } else { - self.useSystemCursor = false - } - - self.showGutter = try container.decodeIfPresent(Bool.self, forKey: .showGutter) ?? true - self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true - self.showFoldingRibbon = try container.decodeIfPresent(Bool.self, forKey: .showFoldingRibbon) ?? true - self.reformatAtColumn = try container.decodeIfPresent(Int.self, forKey: .reformatAtColumn) ?? 80 - self.showReformattingGuide = try container.decodeIfPresent( - Bool.self, - forKey: .showReformattingGuide - ) ?? false - self.invisibleCharacters = try container.decodeIfPresent( - InvisibleCharactersConfig.self, - forKey: .invisibleCharacters - ) ?? .default - self.warningCharacters = try container.decodeIfPresent( - WarningCharacters.self, - forKey: .warningCharacters - ) ?? .default - - self.populateCommands() - } - - /// Adds toggle-able preferences to the command palette via shared `CommandManager` - private func populateCommands() { - let mgr = CommandManager.shared - - mgr.addCommand( - name: "Toggle Type-Over Completion", - title: "Toggle Type-Over Completion", - id: "prefs.text_editing.type_over_completion", - command: { - Settings[\.textEditing].enableTypeOverCompletion.toggle() - } - ) - - mgr.addCommand( - name: "Toggle Autocomplete Braces", - title: "Toggle Autocomplete Braces", - id: "prefs.text_editing.autocomplete_braces", - command: { - Settings[\.textEditing].autocompleteBraces.toggle() - } - ) - - mgr.addCommand( - name: "Toggle Word Wrap", - title: "Toggle Word Wrap", - id: "prefs.text_editing.wrap_lines_to_editor_width", - command: { - Settings[\.textEditing].wrapLinesToEditorWidth.toggle() - } - ) - - mgr.addCommand(name: "Toggle Minimap", title: "Toggle Minimap", id: "prefs.text_editing.toggle_minimap") { - Settings[\.textEditing].showMinimap.toggle() - } - - mgr.addCommand(name: "Toggle Gutter", title: "Toggle Gutter", id: "prefs.text_editing.toggle_gutter") { - Settings[\.textEditing].showGutter.toggle() - } - - mgr.addCommand( - name: "Toggle Folding Ribbon", - title: "Toggle Folding Ribbon", - id: "prefs.text_editing.toggle_folding_ribbon" - ) { - Settings[\.textEditing].showFoldingRibbon.toggle() - } - } - - struct IndentOption: Codable, Hashable { - var indentType: IndentType - // Kept even when `indentType` is `.tab` to retain the user's - // settings when changing `indentType`. - var spaceCount: Int = 4 - - enum IndentType: String, Codable { - case tab - case spaces - } - } - - struct BracketPairEmphasis: Codable, Hashable { - /// The type of highlight to use - var highlightType: HighlightType = .flash - var useCustomColor: Bool = false - /// The color to use for the highlight. - var color: Theme.Attributes = Theme.Attributes(color: "FFFFFF", bold: false, italic: false) - - enum HighlightType: String, Codable { - case disabled - case bordered - case flash - case underline - } - } - - enum OverscrollOption: String, Codable { - case none - case small - case medium - case large - - var overscrollPercentage: CGFloat { - switch self { - case .none: return 0 - case .small: return 0.25 - case .medium: return 0.5 - case .large: return 0.75 - } - } - } - - struct InvisibleCharactersConfig: Equatable, Hashable, Codable { - static var `default`: InvisibleCharactersConfig = { - InvisibleCharactersConfig( - enabled: false, - showSpaces: true, - showTabs: true, - showLineEndings: true - ) - }() - - var enabled: Bool - - var showSpaces: Bool - var showTabs: Bool - var showLineEndings: Bool - - var spaceReplacement: String = "·" - var tabReplacement: String = "→" - - // Controlled by `showLineEndings` - var carriageReturnReplacement: String = "↵" - var lineFeedReplacement: String = "¬" - var paragraphSeparatorReplacement: String = "¶" - var lineSeparatorReplacement: String = "⏎" - } - - struct WarningCharacters: Equatable, Hashable, Codable { - static let `default`: WarningCharacters = WarningCharacters(enabled: true, characters: [ - 0x0003: "End of text", - - 0x00A0: "Non-breaking space", - 0x202F: "Narrow non-breaking space", - 0x200B: "Zero-width space", - 0x200C: "Zero-width non-joiner", - 0x2029: "Paragraph separator", - - 0x2013: "Em-dash", - 0x00AD: "Soft hyphen", - - 0x2018: "Left single quote", - 0x2019: "Right single quote", - 0x201C: "Left double quote", - 0x201D: "Right double quote", - - 0x037E: "Greek Question Mark" - ]) - - var enabled: Bool - var characters: [UInt16: String] - } - } - - struct EditorFont: Codable, Hashable { - /// The font size for the font - var size: Double = 12 - - /// The name of the custom font - var name: String = "SF Mono" - - /// The weight of the custom font - var weight: NSFont.Weight = .medium - - /// Default initializer - init() {} - - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.size = try container.decodeIfPresent(Double.self, forKey: .size) ?? size - self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? name - self.weight = try container.decodeIfPresent(NSFont.Weight.self, forKey: .weight) ?? weight - } - - /// Returns an NSFont representation of the current configuration. - /// - /// Returns the custom font, if enabled and able to be instantiated. - /// Otherwise returns a default system font monospaced. - var current: NSFont { - let customFont = NSFont(name: name, size: size)?.withWeight(weight: weight) - return customFont ?? NSFont.monospacedSystemFont(ofSize: size, weight: .medium) - } - } -} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme+FuzzySearchable.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme+FuzzySearchable.swift deleted file mode 100644 index 88826b20f8..0000000000 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme+FuzzySearchable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Theme+FuzzySearchable.swift -// CodeEdit -// -// Created by Tommy Ludwig on 14.08.24. -// - -import Foundation - -extension Theme: FuzzySearchable { - var searchableString: String { - return id - } -} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift deleted file mode 100644 index 3c9e5e0936..0000000000 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift +++ /dev/null @@ -1,290 +0,0 @@ -// -// ThemeModel+CRUD.swift -// CodeEdit -// -// Created by Austin Condiff on 6/18/24. -// - -import SwiftUI -import UniformTypeIdentifiers - -extension ThemeModel { - /// Loads a theme from a given url and appends it to ``themes``. - /// - Parameter url: The URL of the theme - /// - Returns: A ``Theme`` - private func load(from url: URL) throws -> Theme? { - do { - // get the data from the provided file - let json = try Data(contentsOf: url) - // decode the json into ``Theme`` - let theme = try JSONDecoder().decode(Theme.self, from: json) - return theme - } catch { - print(error) - return nil - } - } - - /// Loads all available themes from `~/Library/Application Support/CodeEdit/Themes/` - /// - /// If no themes are available, it will create a default theme and save - /// it to the location mentioned above. - /// - /// When overrides are found in `~/Library/Application Support/CodeEdit/settings.json` - /// they are applied to the loaded themes without altering the original - /// the files in `~/Library/Application Support/CodeEdit/Themes/`. - func loadThemes() throws { // swiftlint:disable:this function_body_length - if let bundledThemesURL = bundledThemesURL { - // remove all themes from memory - themes.removeAll() - - var isDir: ObjCBool = false - - // check if a themes directory exists, otherwise create one - if !filemanager.fileExists(atPath: themesURL.path, isDirectory: &isDir) { - try filemanager.createDirectory(at: themesURL, withIntermediateDirectories: true) - } - - // get all URLs in users themes folder that end with `.cetheme` - let userDefinedThemeFilenames = try filemanager.contentsOfDirectory(atPath: themesURL.path).filter { - $0.contains(".cetheme") - } - let userDefinedThemeURLs = userDefinedThemeFilenames.map { - themesURL.appending(path: $0) - } - - // get all bundled theme URLs - let bundledThemeFilenames = try filemanager.contentsOfDirectory(atPath: bundledThemesURL.path).filter { - $0.contains(".cetheme") - } - let bundledThemeURLs = bundledThemeFilenames.map { - bundledThemesURL.appending(path: $0) - } - - // combine user theme URLs with bundled theme URLs - let themeURLs = userDefinedThemeURLs + bundledThemeURLs - - let prefs = Settings.shared.preferences - - // load each theme from disk and store in memory - try themeURLs.forEach { fileURL in - if var theme = try load(from: fileURL) { - - // get all properties of terminal and editor colors - guard let terminalColors = try theme.terminal.allProperties() as? [String: Theme.Attributes], - let editorColors = try theme.editor.allProperties() as? [String: Theme.Attributes] - else { - print("error") - // TODO: Throw a proper error - throw NSError() // swiftlint:disable:this discouraged_direct_init - } - - // check if there are any overrides in `settings.json` - if let overrides = prefs.theme.overrides[theme.name]?["terminal"] { - terminalColors.forEach { (key, _) in - if let attributes = overrides[key] { - theme.terminal[key] = attributes - } - } - } - - if let overrides = prefs.theme.overrides[theme.name]?["editor"] { - editorColors.forEach { (key, _) in - if let attributes = overrides[key] { - theme.editor[key] = attributes - } - } - } - - theme.isBundled = fileURL.path.contains(bundledThemesURL.path) - - theme.fileURL = fileURL - - // add the theme to themes array - self.themes.append(theme) - - // if there already is a selected theme in `settings.json` select this theme - // otherwise take the first in the list - self.selectedDarkTheme = self.darkThemes.first { - $0.name == prefs.theme.selectedDarkTheme - } ?? self.darkThemes.first - - self.selectedLightTheme = self.lightThemes.first { - $0.name == prefs.theme.selectedLightTheme - } ?? self.lightThemes.first - - // For selecting the default theme, doing it correctly on startup requires some more logic - let userSelectedTheme = self.themes.first { $0.name == prefs.theme.selectedTheme } - let systemAppearance = NSAppearance.currentDrawing().name - - if userSelectedTheme != nil { - self.selectedTheme = userSelectedTheme - } else { - if systemAppearance == .darkAqua { - self.selectedTheme = self.selectedDarkTheme - } else { - self.selectedTheme = self.selectedLightTheme - } - } - } - } - } - } - - func importTheme() { - let openPanel = NSOpenPanel() - let allowedTypes = [UTType(filenameExtension: "cetheme")!] - - openPanel.prompt = "Import" - openPanel.allowedContentTypes = allowedTypes - openPanel.canChooseFiles = true - openPanel.canChooseDirectories = false - openPanel.allowsMultipleSelection = false - - openPanel.begin { result in - if result.rawValue == NSApplication.ModalResponse.OK.rawValue { - if let url = openPanel.urls.first { - self.duplicate(url) - } - } - } - } - - func duplicate(_ url: URL) { - do { - self.isAdding = true - // Construct the destination file URL - var destinationFileURL = self.themesURL.appending(path: url.lastPathComponent) - - // Extract the base filename and extension - let fileExtension = destinationFileURL.pathExtension - - var fileName = destinationFileURL.deletingPathExtension().lastPathComponent - var newFileName = fileName - - var iterator = 1 - - let isBundled = url.absoluteString.hasPrefix(bundledThemesURL?.absoluteString ?? "") - let isImporting = - !url.absoluteString.hasPrefix(bundledThemesURL?.absoluteString ?? "") - && !url.absoluteString.hasPrefix(themesURL.absoluteString) - - if isBundled { - newFileName = "\(fileName) \(iterator)" - destinationFileURL = self.themesURL - .appending(path: newFileName) - .appendingPathExtension(fileExtension) - } - - // Check if the file already exists - while FileManager.default.fileExists(atPath: destinationFileURL.path) { - fileName = destinationFileURL.deletingPathExtension().lastPathComponent - - // Remove any existing iterator - if let range = fileName.range(of: " \\d+$", options: .regularExpression) { - fileName = String(fileName[..? - - /// Check if url is valid - /// - Parameter url: Url to check - /// - Returns: True if url is valid - func isValidUrl(url: String) -> Bool { - // Doing the same kind of check that Xcode does when cloning - let url = url.lowercased() - if url.starts(with: "http://") && url.count > 7 { - return true - } else if url.starts(with: "https://") && url.count > 8 { - return true - } else if url.starts(with: "git@") && url.count > 4 { - return true - } - return false - } - /// Check if Git is installed - /// - Returns: True if Git is found by running "which git" command - func isGitInstalled() -> Bool { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/which") - process.arguments = ["git"] - let pipe = Pipe() - process.standardOutput = pipe - do { - try process.run() - process.waitUntilExit() - return process.terminationStatus == 0 - } catch { - return false - } - } - - /// Check if clipboard contains git url - func checkClipboard() { - if let url = NSPasteboard.general.pasteboardItems?.first?.string(forType: .string) { - if isValidUrl(url: url) { - self.repoUrlStr = url - } - } - } - - /// Clone repository - func cloneRepository(completionHandler: @escaping (URL) -> Void) { - if !isGitInstalled() { - showAlert( - alertMsg: "Git installation not found.", - infoText: "Ensure Git is installed on your system and try again." - ) - return - } - if repoUrlStr == "" { - showAlert( - alertMsg: "Url cannot be empty", - infoText: "You must specify a repository to clone" - ) - return - } - - // Parsing repo name - guard let remoteUrl = URL(string: repoUrlStr) else { - return - } - - var repoName = remoteUrl.lastPathComponent - - // Strip .git from name if it has it. - // Cloning repository without .git also works - if repoName.contains(".git") { - repoName.removeLast(4) - } - - guard let localPath = getPath(saveName: repoName) else { - return - } - - var isDir: ObjCBool = true - if FileManager.default.fileExists(atPath: localPath.relativePath, isDirectory: &isDir) { - showAlert(alertMsg: "Error", infoText: "Directory already exists") - return - } - - do { - try FileManager.default.createDirectory( - atPath: localPath.relativePath, - withIntermediateDirectories: true, - attributes: nil - ) - } catch { - showAlert(alertMsg: "Failed to create folder", infoText: "\(error)") - return - } - - gitClient = GitClient(directoryURL: localPath, shellClient: .live()) - - self.cloningTask = Task(priority: .background) { - await processCloning( - remoteUrl: remoteUrl, - localPath: localPath, - completionHandler: completionHandler - ) - } - } - - /// Process cloning - /// - Parameters: - /// - remoteUrl: Path to remote repository - /// - localPath: Path to local folder - /// - completionHandler: Completion handler if cloning is successful - private func processCloning( - remoteUrl: URL, - localPath: URL, - completionHandler: @escaping (URL) -> Void - ) async { - guard let gitClient else { return } - - await setIsCloning(true) - - do { - for try await progress in gitClient.cloneRepository(remoteUrl: remoteUrl, localPath: localPath) { - await MainActor.run { - self.cloningProgress = progress - } - } - - if Task.isCancelled { - await MainActor.run { - deleteTemporaryFolder(localPath: localPath) - } - return - } - - completionHandler(localPath) - } catch { - await MainActor.run { - if let error = error as? GitClient.GitClientError { - showAlert(alertMsg: "Failed to clone", infoText: error.description) - } else { - showAlert(alertMsg: "Failed to clone", infoText: error.localizedDescription) - } - deleteTemporaryFolder(localPath: localPath) - } - } - - await setIsCloning(false) - } - - private func deleteTemporaryFolder(localPath: URL) { - do { - try FileManager.default.removeItem(atPath: localPath.relativePath) - } catch { - showAlert(alertMsg: "Failed to delete folder", infoText: "\(error)") - return - } - } - - @MainActor - private func setIsCloning(_ newValue: Bool) { - self.isCloning = newValue - } - - private func getPath(saveName: String) -> URL? { - let dialog = NSSavePanel() - dialog.showsResizeIndicator = true - dialog.showsHiddenFiles = false - dialog.showsTagField = false - dialog.prompt = "Clone" - dialog.nameFieldStringValue = saveName - dialog.nameFieldLabel = "Clone as" - dialog.title = "Clone a Repository" - - guard dialog.runModal() == NSApplication.ModalResponse.OK, - let result = dialog.url else { - return nil - } - - return result - } - - private func showAlert(alertMsg: String, infoText: String) { - let alert = NSAlert() - alert.messageText = alertMsg - alert.informativeText = infoText - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.runModal() - } -} diff --git a/CodeEdit/Features/SourceControl/Models/GitBranch.swift b/CodeEdit/Features/SourceControl/Models/GitBranch.swift deleted file mode 100644 index 1118baf873..0000000000 --- a/CodeEdit/Features/SourceControl/Models/GitBranch.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// GitBranch.swift -// CodeEdit -// -// Created by Albert Vinizhanau on 10/20/23. -// - -import Foundation - -struct GitBranch: Hashable, Identifiable { - let name: String - let longName: String - let upstream: String? - let ahead: Int - let behind: Int - - var id: String { - longName - } - - /// Is local branch - var isLocal: Bool { - return longName.hasPrefix("refs/heads/") - } - - /// Is remote branch - var isRemote: Bool { - return longName.hasPrefix("refs/remotes/") - } -} diff --git a/CodeEdit/Features/SourceControl/Models/GitBranchesGroup.swift b/CodeEdit/Features/SourceControl/Models/GitBranchesGroup.swift deleted file mode 100644 index 6c05097f6b..0000000000 --- a/CodeEdit/Features/SourceControl/Models/GitBranchesGroup.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// GitBranchesGroup.swift -// CodeEdit -// -// Created by Federico Zivolo on 22/01/24. -// - -import Foundation - -struct GitBranchesGroup: Hashable { - let name: String - var branches: [GitBranch] - var shouldNest: Bool { - branches.first?.name.hasPrefix(name + "/") ?? false - } -} diff --git a/CodeEdit/Features/SourceControl/Models/GitRemote.swift b/CodeEdit/Features/SourceControl/Models/GitRemote.swift deleted file mode 100644 index 0edde776e8..0000000000 --- a/CodeEdit/Features/SourceControl/Models/GitRemote.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// GitRemote.swift -// CodeEdit -// -// Created by Austin Condiff on 11/17/23. -// - -import Foundation - -struct GitRemote: Hashable { - let name: String - let pushLocation: String - let fetchLocation: String - var branches: [GitBranch] = [] -} diff --git a/CodeEdit/Features/SourceControl/Models/GitStashEntry.swift b/CodeEdit/Features/SourceControl/Models/GitStashEntry.swift deleted file mode 100644 index cbec68520d..0000000000 --- a/CodeEdit/Features/SourceControl/Models/GitStashEntry.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// GitStashEntry.swift -// CodeEdit -// -// Created by Austin Condiff on 11/20/23. -// - -import Foundation - -struct GitStashEntry: Hashable { - let index: Int - let message: String - let date: Date -} diff --git a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift deleted file mode 100644 index d912cfa0df..0000000000 --- a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift +++ /dev/null @@ -1,300 +0,0 @@ -// -// SourceControlManager+GitClient.swift -// CodeEdit -// -// Created by Austin Condiff on 7/2/24. -// - -import Foundation - -extension SourceControlManager { - /// Validate repository - func validate() async throws { - let isGitRepository = await gitClient.validate() - await MainActor.run { - self.isGitRepository = isGitRepository - } - } - - /// Fetch from remote - func fetch() async throws { - try await gitClient.fetchFromRemote() - await self.refreshNumberOfUnsyncedCommits() - } - - /// Refresh current branch - func refreshCurrentBranch() async { - let currentBranch = try? await gitClient.getCurrentBranch() - await MainActor.run { - self.currentBranch = currentBranch - } - } - - /// Refresh branches - func refreshBranches() async { - let branches = (try? await gitClient.getBranches()) ?? [] - await MainActor.run { - self.branches = branches - } - } - - /// Checkout branch - func checkoutBranch(branch: GitBranch) async throws { - try await gitClient.checkoutBranch(branch) - await refreshBranches() - await refreshCurrentBranch() - } - - /// Create new branch, can be created only from local branch - func newBranch(name: String, from: GitBranch) async throws { - try await gitClient.checkoutBranch(from, newName: name) - await refreshBranches() - await refreshCurrentBranch() - } - - /// Rename branch - func renameBranch(oldName: String, newName: String) async throws { - try await gitClient.renameBranch(oldName: oldName, newName: newName) - await refreshBranches() - } - - /// Delete branch if it's local and not current - func deleteBranch(branch: GitBranch) async throws { - if !branch.isLocal || branch == currentBranch { - return - } - - try await gitClient.deleteBranch(branch) - await refreshBranches() - } - - /// Delete stash entry - func deleteStashEntry(stashEntry: GitStashEntry) async throws { - try await gitClient.deleteStashEntry(stashEntry.index) - try await refreshStashEntries() - } - - /// Apply stash entry - func applyStashEntry(stashEntry: GitStashEntry) async throws { - try await gitClient.applyStashEntry(stashEntry.index) - try await refreshStashEntries() - await refreshAllChangedFiles() - } - - /// Stash changes - func stashChanges(message: String?) async throws { - try await gitClient.stash(message: message) - try await refreshStashEntries() - await refreshAllChangedFiles() - } - - /// Delete remote - func deleteRemote(remote: GitRemote) async throws { - try await gitClient.removeRemote(name: remote.name) - try await refreshRemotes() - } - - /// Discard changes for file - func discardChanges(for file: URL) { - Task { - do { - try await gitClient.discardChanges(for: file) - // TODO: Refresh content of active and unmodified document, - // requires CodeEditSourceEditor changes - } catch { - logger.error("Failed to discard changes for file (\(file.lastPathComponent): \(error)") - await showAlertForError(title: "Failed to discard changes", error: error) - } - } - } - - /// Discard changes for repository - func discardAllChanges() { - Task { - do { - try await gitClient.discardAllChanges() - // TODO: Refresh content of active and unmodified document, - // requires CodeEditSourceEditor changes - } catch { - logger.error("Failed to discard changes: \(error)") - await showAlertForError(title: "Failed to discard changes", error: error) - } - } - } - - /// Set changed files on main actor - @MainActor - private func setChangedFiles(_ files: [GitChangedFile]) { - self.changedFiles = files - } - - /// Refresh git status for files in project navigator - @MainActor - private func refreshStatusInFileManager() { - guard let fileManager = fileManager else { - return - } - - var updatedStatusFor: Set = [] - // Refresh status of file manager files - for changedFile in changedFiles { - guard let file = fileManager.getFile(changedFile.ceFileKey) else { - continue - } - if file.gitStatus != changedFile.anyStatus() { - file.gitStatus = changedFile.anyStatus() - } - updatedStatusFor.insert(file) - } - - for (_, file) in fileManager.flattenedFileItems - where !updatedStatusFor.contains(file) && file.gitStatus != nil { - file.gitStatus = nil - updatedStatusFor.insert(file) - } - - if updatedStatusFor.isEmpty { - return - } - - fileManager.notifyObservers(updatedItems: updatedStatusFor) - } - - /// Refresh all changed files and refresh status in file manager - func refreshAllChangedFiles() async { - do { - let status = try await gitClient.getStatus() - - // TODO: Unmerged changes - // status.unmergedChanges - - await setChangedFiles(status.changedFiles + status.untrackedFiles) - await refreshStatusInFileManager() - } catch GitClient.GitClientError.notGitRepository { - await setChangedFiles([]) - } catch { - logger.error("Error fetching git status: \(error)") - await setChangedFiles([]) - } - } - - /// Get all changed files for a commit - func getCommitChangedFiles(commitSHA: String) async -> [GitChangedFile] { - do { - return try await gitClient.getCommitChangedFiles(commitSHA: commitSHA) - } catch { - logger.error("Error committing changed files: \(error)") - return [] - } - } - - /// Commit files selected by user - func commit(message: String, details: String? = nil) async throws { - try await gitClient.commit(message: message, details: details) - - await self.refreshAllChangedFiles() - await self.refreshNumberOfUnsyncedCommits() - } - - /// Adds the given URLs to the staged changes. - /// - Parameter files: The files to stage. - func add(_ files: [URL]) async throws { - try await gitClient.add(files) - } - - /// Removes the given URLs from the staged changes. - /// - Parameter files: The URLs to un-stage. - func reset(_ files: [URL]) async throws { - try await gitClient.reset(files) - } - - /// Refresh number of unsynced commits - func refreshNumberOfUnsyncedCommits() async { - let numberOfUnpushedCommits = (try? await gitClient.numberOfUnsyncedCommits()) ?? (ahead: 0, behind: 0) - - await MainActor.run { - self.numberOfUnsyncedCommits = numberOfUnpushedCommits - } - } - - /// Add existing remote to git - func addRemote(name: String, location: String) async throws { - try await gitClient.addRemote(name: name, location: location) - try await refreshRemotes() - } - - /// Get all remotes - func refreshRemotes() async throws { - let remotes = (try? await gitClient.getRemotes()) ?? [] - await MainActor.run { - self.remotes = remotes - } - if !remotes.isEmpty { - try await self.refreshAllRemotesBranches() - } - } - - /// Refresh branches for all remotes - func refreshAllRemotesBranches() async throws { - for remote in remotes { - try await refreshRemoteBranches(remote: remote) - } - } - - /// Refresh branches for a specific remote - func refreshRemoteBranches(remote: GitRemote) async throws { - let branches = try await getRemoteBranches(remote: remote.name) - if let index = remotes.firstIndex(of: remote) { - await MainActor.run { - remotes[index].branches = branches - } - } - - } - - /// Get branches for a specific remote - func getRemoteBranches(remote: String) async throws -> [GitBranch] { - try await gitClient.getBranches(remote: remote) - } - - func refreshStashEntries() async throws { - let stashEntries = (try? await gitClient.stashList()) ?? [] - await MainActor.run { - self.stashEntries = stashEntries - } - } - - /// Pull changes from remote - func pull(remote: String? = nil, branch: String? = nil, rebase: Bool = false) async throws { - try await gitClient.pullFromRemote(remote: remote, branch: branch, rebase: rebase) - - await self.refreshNumberOfUnsyncedCommits() - } - - /// Push changes to remote - func push( - remote: String? = nil, - branch: String? = nil, - setUpstream: Bool = false, - force: Bool = false, - tags: Bool = false - ) async throws { - guard currentBranch != nil else { return } - - try await gitClient.pushToRemote( - remote: remote, - branch: branch, - setUpstream: setUpstream, - force: force, - tags: tags - ) - - await refreshCurrentBranch() - await self.refreshNumberOfUnsyncedCommits() - } - - /// Initiate repository - func initiate() async throws { - try await gitClient.initiate() - } -} diff --git a/CodeEdit/Features/SourceControl/SourceControlManager.swift b/CodeEdit/Features/SourceControl/SourceControlManager.swift deleted file mode 100644 index 9a42cf7f1c..0000000000 --- a/CodeEdit/Features/SourceControl/SourceControlManager.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// SourceControlModel.swift -// CodeEdit -// -// Created by Nanashi Li on 2022/05/20. -// - -import Foundation -import AppKit -import OSLog - -/// This class is used to perform git functions such as fetch, pull, add/remove of changes, commit, push, etc. -/// It also stores remotes, branches, current changes, stashes, and commits -final class SourceControlManager: ObservableObject { - let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "SourceControlManager") - - let gitClient: GitClient - - /// The base URL of the workspace - let workspaceURL: URL - - let editorManager: EditorManager - weak var fileManager: CEWorkspaceFileManager? - - /// A list of changed files - @Published var changedFiles: [GitChangedFile] = [] - - /// Current branch - @Published var currentBranch: GitBranch? - - /// All branches, local and remote - @Published var branches: [GitBranch] = [] - - /// All remotes - @Published var remotes: [GitRemote] = [] - - /// All stashed entries - @Published var stashEntries: [GitStashEntry] = [] - - /// Number of unsynced commits with remote in current branch - @Published var numberOfUnsyncedCommits: (ahead: Int, behind: Int) = (ahead: 0, behind: 0) - - /// Is project a git repository - @Published var isGitRepository: Bool = false - - /// Is the push sheet presented - @Published var pushSheetIsPresented: Bool = false { - didSet { - self.operationBranch = nil - self.operationRebase = false - self.operationForce = false - self.operationIncludeTags = false - } - } - - /// Is the pull sheet presented - @Published var pullSheetIsPresented: Bool = false { - didSet { - self.operationBranch = nil - self.operationRebase = false - self.operationForce = false - self.operationIncludeTags = false - } - } - - /// Is the fetch sheet presented - @Published var fetchSheetIsPresented: Bool = false - - /// Is the stash sheet presented - @Published var stashSheetIsPresented: Bool = false - - /// Is the remote sheet presented - @Published var addExistingRemoteSheetIsPresented: Bool = false - - /// Branch selected for source control operations - @Published var operationBranch: GitBranch? - - /// Remote selected for source control operations - @Published var operationRemote: GitRemote? - - /// Rebase boolean set for source control operations - @Published var operationRebase: Bool = false - - /// Force boolean set for source control operations - @Published var operationForce: Bool = false - - /// Include tags boolean set for source control operations - @Published var operationIncludeTags: Bool = false - - /// Branch to switch to - @Published var switchToBranch: GitBranch? - - /// Is discard all alert presented - @Published var discardAllAlertIsPresented: Bool = false - - /// Is no changes to stage alert presented - @Published var noChangesToStageAlertIsPresented: Bool = false - - /// Is no changes to unstage alert presented - @Published var noChangesToUnstageAlertIsPresented: Bool = false - - /// Is no changes to stash alert presented - @Published var noChangesToStashAlertIsPresented: Bool = false - - /// Is no changes to discard alert presented - @Published var noChangesToDiscardAlertIsPresented: Bool = false - - var orderedLocalBranches: [GitBranch] { - var orderedBranches: [GitBranch] = [currentBranch].compactMap { $0 } - let otherBranches = branches.filter { $0.isLocal && $0 != currentBranch } - .sorted { $0.name.lowercased() < $1.name.lowercased() } - orderedBranches.append(contentsOf: otherBranches) - return orderedBranches - } - - init( - workspaceURL: URL, - editorManager: EditorManager - ) { - self.workspaceURL = workspaceURL - self.editorManager = editorManager - gitClient = GitClient(directoryURL: workspaceURL, shellClient: currentWorld.shellClient) - } - - /// Show alert for error - func showAlertForError(title: String, error: Error) async { - if let error = error as? GitClient.GitClientError { - await showAlert(title: title, message: error.description) - return - } - - if let error = error as? LocalizedError { - var description = error.errorDescription ?? "" - if let failureReason = error.failureReason { - if description.isEmpty { - description += failureReason - } else { - description += "\n\n" + failureReason - } - } - - if let recoverySuggestion = error.recoverySuggestion { - if description.isEmpty { - description += recoverySuggestion - } else { - description += "\n\n" + recoverySuggestion - } - } - - await showAlert(title: title, message: description) - } else { - await showAlert(title: title, message: error.localizedDescription) - } - } - - private func showAlert(title: String, message: String) async { - await MainActor.run { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.runModal() - } - } -} diff --git a/CodeEdit/Features/SplitView/Model/Environment+ContentInsets.swift b/CodeEdit/Features/SplitView/Model/Environment+ContentInsets.swift deleted file mode 100644 index 0c8f577f64..0000000000 --- a/CodeEdit/Features/SplitView/Model/Environment+ContentInsets.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Environment+ContentInsets.swift -// CodeEdit -// -// Created by Wouter Hennen on 24/02/2023. -// - -import SwiftUI - -struct EdgeInsetsEnvironmentKey: EnvironmentKey { - static var defaultValue: EdgeInsets = EdgeInsets(top: 1, leading: 0, bottom: 0, trailing: 0) -} - -extension EnvironmentValues { - var edgeInsets: EdgeInsetsEnvironmentKey.Value { - get { self[EdgeInsetsEnvironmentKey.self] } - set { self[EdgeInsetsEnvironmentKey.self] = newValue } - } -} - -extension EdgeInsets { - var nsEdgeInsets: NSEdgeInsets { - .init(top: top, left: leading, bottom: bottom, right: trailing) - } -} diff --git a/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift b/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift deleted file mode 100644 index 95f4e01bb1..0000000000 --- a/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// SplitViewModifiers.swift -// CodeEdit -// -// Created by Wouter Hennen on 05/03/2023. -// - -import SwiftUI - -struct SplitViewControllerLayoutValueKey: _ViewTraitKey { - static var defaultValue: () -> SplitViewController? = { nil } -} - -struct SplitViewItemCollapsedViewTraitKey: _ViewTraitKey { - static var defaultValue: Binding = .constant(false) -} - -struct SplitViewItemCanCollapseViewTraitKey: _ViewTraitKey { - static var defaultValue: Bool = false -} - -struct SplitViewHoldingPriorityTraitKey: _ViewTraitKey { - static var defaultValue: NSLayoutConstraint.Priority = .defaultLow -} - -struct SplitViewItemCanAnimateViewTraitKey: _ViewTraitKey { - static var defaultValue: Bool { true } -} - -extension View { - func collapsed(_ value: Binding) -> some View { - self - // Use get/set instead of binding directly, so a view update will be triggered if the binding changes. - ._trait(SplitViewItemCollapsedViewTraitKey.self, .init { - value.wrappedValue - } set: { - value.wrappedValue = $0 - }) - } - - func collapsable() -> some View { - self - ._trait(SplitViewItemCanCollapseViewTraitKey.self, true) - } - - func holdingPriority(_ priority: NSLayoutConstraint.Priority) -> some View { - self - ._trait(SplitViewHoldingPriorityTraitKey.self, priority) - } - - func splitViewCanAnimate(_ enabled: Binding) -> some View { - self._trait(SplitViewItemCanAnimateViewTraitKey.self, enabled.wrappedValue) - } -} diff --git a/CodeEdit/Features/SplitView/Views/Variadic.swift b/CodeEdit/Features/SplitView/Views/Variadic.swift deleted file mode 100644 index b5ab5aecbe..0000000000 --- a/CodeEdit/Features/SplitView/Views/Variadic.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Variadic.swift -// CodeEdit -// -// Created by Wouter Hennen on 05/03/2023. -// - -import SwiftUI - -struct Helper: _VariadicView_UnaryViewRoot { - var _body: (_VariadicView.Children) -> Result - - func body(children: _VariadicView.Children) -> some View { - _body(children) - } -} - -extension View { - - /// Exposes the children of a ViewBuilder so they can be accessed individually. - func variadic(@ViewBuilder process: @escaping (_VariadicView.Children) -> R) -> some View { - _VariadicView.Tree(Helper(_body: process), content: { self }) - } -} diff --git a/CodeEdit/Features/StatusBar/ViewModifiers/UpdateStatusBarInfo.swift b/CodeEdit/Features/StatusBar/ViewModifiers/UpdateStatusBarInfo.swift deleted file mode 100644 index 271141b721..0000000000 --- a/CodeEdit/Features/StatusBar/ViewModifiers/UpdateStatusBarInfo.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// UpdateStatusBarInfo.swift -// CodeEdit -// -// Created by Paul Ebose on 2024/5/12. -// - -import SwiftUI - -/// Updates ``StatusBarFileInfoView``'s `fileSize` and `dimensions`. -/// ```swift -/// FileView -/// .modifier(UpdateStatusBarInfo(withURL)) -/// ``` -struct UpdateStatusBarInfo: ViewModifier { - - /// The URL of the file to compute information from. - let fileURL: URL? - - init(with fileURL: URL?) { - self.fileURL = fileURL - } - - @EnvironmentObject private var editorManager: EditorManager - @EnvironmentObject private var statusBarViewModel: StatusBarViewModel - - /// This is returned by ``UpdateStatusBarInfo`` `.computeStatusBarInfo`. - private struct ComputedStatusBarInfo { - let fileSize: Int - let dimensions: ImageDimensions? - } - - /// Compute information that can be used to update properties in ``StatusBarFileInfoView``. - /// - Parameter with fileURL: URL of the file to compute information from. - /// - Returns: The file size and its image dimensions (if any). - private func computeStatusBarInfo(with fileURL: URL) -> ComputedStatusBarInfo? { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.contentTypeKey, .fileSizeKey]), - let contentType = resourceValues.contentType, - let fileSize = resourceValues.fileSize - else { - return nil - } - - if contentType.conforms(to: .image), let imageReps = NSImage(contentsOf: fileURL)?.representations.first { - let dimensions = ImageDimensions(width: imageReps.pixelsWide, height: imageReps.pixelsHigh) - return ComputedStatusBarInfo(fileSize: fileSize, dimensions: dimensions) - } else { // non-image file - return ComputedStatusBarInfo(fileSize: fileSize, dimensions: nil) - } - } - - func body(content: Content) -> some View { - if let fileURL { - content - .onAppear { - let statusBarInfo = computeStatusBarInfo(with: fileURL) - statusBarViewModel.fileSize = statusBarInfo?.fileSize - statusBarViewModel.dimensions = statusBarInfo?.dimensions - } - .onChange(of: editorManager.activeEditor.selectedTab) { _, newTab in - guard let newTab else { return } - let statusBarInfo = computeStatusBarInfo(with: newTab.file.url) - statusBarViewModel.fileSize = statusBarInfo?.fileSize - statusBarViewModel.dimensions = statusBarInfo?.dimensions - } - } else { - content - } - } - -} diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift deleted file mode 100644 index 6939fca4ec..0000000000 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// StatusBarCursorPositionLabel.swift -// CodeEdit -// -// Created by Lukas Pistrol on 22.03.22. -// - -import SwiftUI -import Combine -import CodeEditSourceEditor - -struct StatusBarCursorPositionLabel: View { - @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - @EnvironmentObject private var editorManager: EditorManager - - @State private var tab: EditorInstance? - - /// Updates the source of cursor position notifications. - func updateSource() { - tab = editorManager.activeEditor.selectedTab - } - - var body: some View { - Group { - if let currentTab = tab { - LineLabel(editorInstance: currentTab) - } else { - Text("").accessibilityLabel("No Selection") - } - } - .fixedSize() - .accessibilityIdentifier("CursorPositionLabel") - .accessibilityAddTraits(.updatesFrequently) - .onHover { isHovering($0) } - .onAppear { - updateSource() - } - .onReceive(editorManager.tabBarTabIdSubject) { _ in - updateSource() - } - } - - struct LineLabel: View { - @Environment(\.modifierKeys) - private var modifierKeys - @Environment(\.controlActiveState) - private var controlActive - - @EnvironmentObject private var statusBarViewModel: StatusBarViewModel - - let editorInstance: EditorInstance - - @State private var cursorPositions: [CursorPosition] = [] - - init(editorInstance: EditorInstance) { - self.editorInstance = editorInstance - } - - var body: some View { - Text(getLabel()) - .font(statusBarViewModel.statusBarFont) - .foregroundColor(foregroundColor) - .lineLimit(1) - .onReceive(editorInstance.$cursorPositions) { newValue in - self.cursorPositions = newValue - } - } - - private var foregroundColor: Color { - if controlActive == .inactive { - Color(nsColor: .disabledControlTextColor) - } else { - Color(nsColor: .secondaryLabelColor) - } - } - - /// Finds the lines contained by a range in the currently selected document. - /// - Parameter range: The range to query. - /// - Returns: The number of lines in the range. - func getLines(_ range: NSRange) -> Int { - return editorInstance.rangeTranslator.linesInRange(range) - } - - /// Create a label string for cursor positions. - /// - Returns: A string describing the user's location in a document. - func getLabel() -> String { - if cursorPositions.isEmpty { - return "" - } - - // More than one selection, display the number of selections. - if cursorPositions.count > 1 { - return "\(cursorPositions.count) selected ranges" - } - - // If the selection is more than just a cursor, return the length. - if cursorPositions[0].range.length > 0 { - // When the option key is pressed display the character range. - if modifierKeys.contains(.option) { - return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)" - } - - let lineCount = getLines(cursorPositions[0].range) - - if lineCount > 1 { - return "\(lineCount) lines" - } - - return "\(cursorPositions[0].range.length) characters" - } - - // When the option key is pressed display the character offset. - if modifierKeys.contains(.option) { - return "Char: \(cursorPositions[0].range.location) Len: 0" - } - - // When there's a single cursor, display the line and column. - return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column)" - } - } -} diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarFileInfoView.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarFileInfoView.swift deleted file mode 100644 index 58d6896d64..0000000000 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarFileInfoView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// StatusBarFileInfoView.swift -// CodeEdit -// -// Created by Paul Ebose on 2024/5/12. -// - -import SwiftUI - -/// Shows media information about the currently opened file. -/// -/// This currently shows the file size and image dimensions, if available. -struct StatusBarFileInfoView: View { - - @EnvironmentObject private var statusBarViewModel: StatusBarViewModel - - private let dimensionsNumberStyle = IntegerFormatStyle(locale: Locale(identifier: "en_US")).grouping(.never) - - var body: some View { - - HStack(spacing: 15) { - - if let dimensions = statusBarViewModel.dimensions { - let width = dimensionsNumberStyle.format(dimensions.width) - let height = dimensionsNumberStyle.format(dimensions.height) - - Text("\(width) × \(height)") - } - - if let fileSize = statusBarViewModel.fileSize { - Text(fileSize.formatted(.byteCount(style: .memory))) - } - - } - .font(statusBarViewModel.statusBarFont) - .foregroundStyle(statusBarViewModel.foregroundStyle) - } - -} diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift b/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift deleted file mode 100644 index 2056b6d7d7..0000000000 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTab.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// UtilityAreaTab.swift -// CodeEdit -// -// Created by Wouter Hennen on 02/06/2023. -// - -import SwiftUI - -enum UtilityAreaTab: WorkspacePanelTab, CaseIterable { - var id: Self { self } - - case terminal - case debugConsole - case output - - var title: String { - switch self { - case .terminal: - return "Terminal" - case .debugConsole: - return "Debug Console" - case .output: - return "Output" - } - } - - var systemImage: String { - switch self { - case .terminal: - return "terminal" - case .debugConsole: - return "ladybug" - case .output: - return "list.bullet.indent" - } - } - - var body: some View { - switch self { - case .terminal: - UtilityAreaTerminalView() - case .debugConsole: - UtilityAreaDebugView() - case .output: - UtilityAreaOutputView() - } - } -} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift deleted file mode 100644 index 60e29c0d89..0000000000 --- a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// LanguageServerLogContainer.swift -// CodeEdit -// -// Created by Khan Winter on 7/18/25. -// - -import OSLog -import LanguageServerProtocol - -class LanguageServerLogContainer: UtilityAreaOutputSource { - struct LanguageServerMessage: UtilityAreaOutputMessage { - let log: LogMessageParams - var id: UUID = UUID() - - var message: String { - log.message - } - - var level: UtilityAreaLogLevel { - switch log.type { - case .error: - .error - case .warning: - .warning - case .info: - .info - case .log: - .debug - } - } - - var date: Date = Date() - var subsystem: String? - var category: String? - } - - let id: String - - private var streamContinuation: AsyncStream.Continuation - private var stream: AsyncStream - private(set) var logs: [LanguageServerMessage] = [] - - init(language: LanguageIdentifier) { - id = language.rawValue - (stream, streamContinuation) = AsyncStream.makeStream( - bufferingPolicy: .bufferingNewest(0) - ) - } - - func appendLog(_ log: LogMessageParams) { - let message = LanguageServerMessage(log: log) - logs.append(message) - streamContinuation.yield(message) - } - - func cachedMessages() -> [LanguageServerMessage] { - logs - } - - func streamMessages() -> AsyncStream { - stream - } -} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift deleted file mode 100644 index 4d65d39d80..0000000000 --- a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// UtilityAreaOutputSourcePicker.swift -// CodeEdit -// -// Created by Khan Winter on 7/18/25. -// - -import SwiftUI - -struct UtilityAreaOutputSourcePicker: View { - typealias Sources = UtilityAreaOutputView.Sources - - @EnvironmentObject private var workspace: WorkspaceDocument - - @AppSettings(\.developerSettings.showInternalDevelopmentInspector) - var showInternalDevelopmentInspector - - @Binding var selectedSource: Sources? - - @ObservedObject var extensionManager = ExtensionManager.shared - - @Service var lspService: LSPService - @State private var updater: UUID = UUID() - @State private var languageServerClients: [LSPService.LanguageServerType] = [] - - var body: some View { - Picker("Output Source", selection: $selectedSource) { - if selectedSource == nil { - Text("No Selected Output Source") - .italic() - .tag(Sources?.none) - Divider() - } - - if languageServerClients.isEmpty { - Text("No Language Servers") - } else { - ForEach(languageServerClients, id: \.languageId) { server in - Text(Sources.languageServer(server.logContainer).title) - .tag(Sources.languageServer(server.logContainer)) - } - } - - Divider() - - if extensionManager.extensions.isEmpty { - Text("No Extensions") - } else { - ForEach(extensionManager.extensions) { extensionInfo in - Text(Sources.extensions(.init(extensionInfo: extensionInfo)).title) - .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) - } - } - - if showInternalDevelopmentInspector { - Divider() - Text(Sources.devOutput.title) - .tag(Sources.devOutput) - } - } - .id(updater) - .buttonStyle(.borderless) - .labelsHidden() - .controlSize(.small) - .onAppear { - updateLanguageServers(lspService.languageClients) - } - .onReceive(lspService.$languageClients) { clients in - updateLanguageServers(clients) - } - .onReceive(extensionManager.$extensions) { _ in - updater = UUID() - } - } - - func updateLanguageServers(_ clients: [LSPService.ClientKey: LSPService.LanguageServerType]) { - languageServerClients = clients - .compactMap { (key, value) in - if key.workspacePath == workspace.fileURL?.absolutePath { - return value - } - return nil - } - .sorted(by: { $0.languageId.rawValue < $1.languageId.rawValue }) - if selectedSource == nil, let client = languageServerClients.first { - selectedSource = Sources.languageServer(client.logContainer) - } - updater = UUID() - } -} diff --git a/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift b/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift deleted file mode 100644 index 92c4188c41..0000000000 --- a/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// UtilityAreaView.swift -// CodeEdit -// -// Created by Lukas Pistrol on 22.03.22. -// - -import SwiftUI - -struct UtilityAreaView: View { - @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - - var body: some View { -// WorkspacePanelView( -// viewModel: utilityAreaViewModel, -// selectedTab: $utilityAreaViewModel.selectedTab, -// tabItems: $utilityAreaViewModel.tabItems, -// sidebarPosition: .side, -// darkDivider: true, -// padSideItemVertically: true -// ) - utilityAreaViewModel.selectedTab - .accessibilityElement(children: .contain) - .accessibilityLabel("Utility Area") - .accessibilityIdentifier("UtilityArea") - } -} diff --git a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift deleted file mode 100644 index 5e2d664134..0000000000 --- a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// CodeEditCommands.swift -// CodeEdit -// -// Created by Wouter Hennen on 11/03/2023. -// - -import SwiftUI - -struct CodeEditCommands: Commands { - @AppSettings(\.sourceControl.general.sourceControlIsEnabled) - private var sourceControlIsEnabled - - var body: some Commands { - Group { // SwiftUI limits to 9 items in an initializer, so we have to group every 9 items. - MainCommands() - FileCommands() - ViewCommands() - FindCommands() - NavigateCommands() - TasksCommands() - if sourceControlIsEnabled { SourceControlCommands() } - EditorCommands() - ExtensionCommands() - WindowCommands() - } - HelpCommands() - } -} diff --git a/CodeEdit/Info.plist b/CodeEdit/Info.plist index 56f0b08b94..cbb70151bf 100644 --- a/CodeEdit/Info.plist +++ b/CodeEdit/Info.plist @@ -1247,8 +1247,6 @@ public.folder - NSDocumentClass - WorkspaceDocument CFBundleExecutable diff --git a/CodeEdit/Utils/Extensions/Bundle/Bundle+Info.swift b/CodeEdit/Utils/Bundle+Info.swift similarity index 100% rename from CodeEdit/Utils/Extensions/Bundle/Bundle+Info.swift rename to CodeEdit/Utils/Bundle+Info.swift diff --git a/CodeEdit/Utils/Date+Formatted.swift b/CodeEdit/Utils/Date+Formatted.swift new file mode 100644 index 0000000000..7607b231cb --- /dev/null +++ b/CodeEdit/Utils/Date+Formatted.swift @@ -0,0 +1,21 @@ +// +// Date+Formatted.swift +// CodeEditModules/CodeEditUtils +// +// Created by Lukas Pistrol on 20.04.22. +// + +import Foundation + +extension Date { + + static var logFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSSS" + return formatter + }() + + func logFormatted() -> String { + Self.logFormatter.string(from: self) + } +} diff --git a/CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift b/CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift deleted file mode 100644 index b64dc7f61c..0000000000 --- a/CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// LazyServiceWrapper.swift -// CodeEdit -// -// Created by Khan Winter on 9/9/24. -// - -/// A property wrapper that provides lazily-loaded access to a service instance. -/// -/// Using this wrapper, the service is only resolved when the property is first accessed. -@propertyWrapper -struct LazyService { - private let type: ServiceType - private var service: Service? - - init(_ type: ServiceType = .singleton) { - self.type = type - } - - var wrappedValue: Service { - mutating get { - if let service { - return service - } else { - guard let resolvedService = ServiceContainer.resolve(type, Service.self) else { - let serviceName = String(describing: Service.self) - fatalError("No service of type \(serviceName) registered!") - } - self.service = resolvedService - return resolvedService - } - } mutating set { - self.service = newValue - } - } -} diff --git a/CodeEdit/Utils/DependencyInjection/ServiceContainer.swift b/CodeEdit/Utils/DependencyInjection/ServiceContainer.swift deleted file mode 100644 index 21234637a8..0000000000 --- a/CodeEdit/Utils/DependencyInjection/ServiceContainer.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// ServiceContainer.swift -// CodeEdit -// -// Created by Abe Malla on 4/3/24. -// - -import Foundation - -/// A service container that manages the registration and resolution of services. -enum ServiceContainer { - /// A dictionary storing the closures for creating service instances. - private static var factories: [ObjectIdentifier: () -> Any] = [:] - /// A dictionary storing the cached service instances. - private static var cache: [ObjectIdentifier: Any] = [:] - /// A dispatch queue used for synchronizing access to the factories and cache. - private static let queue = DispatchQueue(label: "ServiceContainerQueue") - - /// Registers a factory closure for creating instances of a service type. - /// - /// - Parameter factory: An autoclosure that returns an instance of the service type. - static func register(_ factory: @autoclosure @escaping () -> Service) { - queue.sync { - let key = ObjectIdentifier(Service.Type.self) - factories[key] = factory - } - } - - /// Resolves an instance of a service type based on the specified resolution type. - /// - /// - Parameters: - /// - resolveType: The type of resolution to use for the service. Defaults to `.singleton`. - /// - type: The type of the service to resolve. - /// - Returns: An instance of the resolved service type, or `nil` if the service is not registered. - static func resolve(_ resolveType: ServiceType = .singleton, _ type: Service.Type) -> Service? { - let serviceId = ObjectIdentifier(Service.Type.self) - - return queue.sync { - switch resolveType { - case .singleton: - if let service = cache[serviceId] as? Service { - return service - } else { - let service = factories[serviceId]?() as? Service - - if let service = service { - cache[serviceId] = service - } - - return service - } - case .newSingleton: - let service = factories[serviceId]?() as? Service - - if let service = service { - cache[serviceId] = service - } - - return service - case .new: - return factories[serviceId]?() as? Service - } - } - } -} diff --git a/CodeEdit/Utils/DependencyInjection/ServiceType.swift b/CodeEdit/Utils/DependencyInjection/ServiceType.swift deleted file mode 100644 index 536e89db06..0000000000 --- a/CodeEdit/Utils/DependencyInjection/ServiceType.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ServiceType.swift -// CodeEdit -// -// Created by Abe Malla on 4/3/24. -// - -/// Defines the type of service instantiation strategy. -enum ServiceType { - /// Returns a new singleton on the first call, then returns a cached one every other time - case singleton - /// Creates a new singleton reference each time and caches it, returning the newer singleton - case newSingleton - /// Creates a new singleton - case new -} diff --git a/CodeEdit/Utils/DependencyInjection/ServiceWrapper.swift b/CodeEdit/Utils/DependencyInjection/ServiceWrapper.swift deleted file mode 100644 index 3f5d7824b1..0000000000 --- a/CodeEdit/Utils/DependencyInjection/ServiceWrapper.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// ServiceWrapper.swift -// CodeEdit -// -// Created by Abe Malla on 4/3/24. -// - -/// A property wrapper that provides access to a service instance. -@propertyWrapper -struct Service { - var service: Service - - init(_ type: ServiceType = .singleton) { - guard let service = ServiceContainer.resolve(type, Service.self) else { - let serviceName = String(describing: Service.self) - fatalError("No service of type \(serviceName) registered!") - } - - self.service = service - } - - var wrappedValue: Service { - get { self.service } - mutating set { service = newValue } - } -} diff --git a/CodeEdit/Utils/Environment/Env+Window.swift b/CodeEdit/Utils/Environment/Env+Window.swift deleted file mode 100644 index 15b0c414c6..0000000000 --- a/CodeEdit/Utils/Environment/Env+Window.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Env+Window.swift -// CodeEdit -// -// Created by Wouter Hennen on 14/01/2023. -// - -import SwiftUI - -struct WindowBox { - weak var value: NSWindow? -} - -struct NSWindowEnvironmentKey: EnvironmentKey { - typealias Value = WindowBox - static var defaultValue = WindowBox(value: nil) -} - -extension EnvironmentValues { - var window: WindowBox { - get { self[NSWindowEnvironmentKey.self] } - set { self[NSWindowEnvironmentKey.self] = newValue } - } -} diff --git a/CodeEdit/Utils/Extensions/NSWindow/NSWindow+Child.swift b/CodeEdit/Utils/Extensions/NSWindow/NSWindow+Child.swift deleted file mode 100644 index 2686fb13d3..0000000000 --- a/CodeEdit/Utils/Extensions/NSWindow/NSWindow+Child.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// NSWindow+Child.swift -// CodeEdit -// -// Created by Axel Martinez on 8/4/24. -// - -import AppKit - -extension NSWindow { - func addCenteredChildWindow(_ childWindow: NSWindow, over parentWindow: NSWindow) { - let parentFrame = parentWindow.frame - let parentCenterX = parentFrame.origin.x + (parentFrame.size.width / 2) - let parentCenterY = parentFrame.origin.y + (parentFrame.size.height / 2) - - let childWidth = childWindow.frame.size.width - let childHeight = childWindow.frame.size.height - let newChildOriginX = parentCenterX - (childWidth / 2) - let newChildOriginY = parentCenterY - (childHeight / 2) - - childWindow.setFrameOrigin(NSPoint(x: newChildOriginX, y: newChildOriginY)) - - parentWindow.addChildWindow(childWindow, ordered: .above) - } -} diff --git a/CodeEdit/Utils/Extensions/String/String+AppearancesOfSubstring.swift b/CodeEdit/Utils/Extensions/String/String+AppearancesOfSubstring.swift deleted file mode 100644 index c557a9df13..0000000000 --- a/CodeEdit/Utils/Extensions/String/String+AppearancesOfSubstring.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// String+AppearancesOfSubstring.swift -// CodeEdit -// -// Created by Tommy Ludwig on 24.11.23. -// - -import Foundation - -extension String { - /// Finds the appearances of a substring within the string. - /// - Parameters: - /// - substring: The substring to search for within the string. - /// - toLeft: The optional number of characters to include to the left of each found substring appearance. - /// - toRight: The optional number of characters to include to the right of each found substring appearance. - /// - /// - Returns: An array of ranges representing the appearances of the substring within the string. - func appearancesOfSubstring(substring: String, toLeft: Int=0, toRight: Int=0) -> [Range] { - guard !substring.isEmpty && self.contains(substring) else { return [] } - var appearances: [Range] = [] - for (index, character) in self.enumerated() where character == substring.first { - let startOfFoundCharacter = self.index(self.startIndex, offsetBy: index) - guard index + substring.count < self.count else { continue } - let lengthOfFoundCharacter = self.index(self.startIndex, offsetBy: (substring.count + index)) - if self[startOfFoundCharacter.. Character? { - guard index < self.count else { - return nil - } - - return self[self.index(self.startIndex, offsetBy: index)] - } -} diff --git a/CodeEdit/Utils/Extensions/String/String+Ranges.swift b/CodeEdit/Utils/Extensions/String/String+Ranges.swift deleted file mode 100644 index 26a84adcf8..0000000000 --- a/CodeEdit/Utils/Extensions/String/String+Ranges.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// String+Ranges.swift -// CodeEdit -// -// Created by Ziyuan Zhao on 2022/3/21. -// - -import Foundation - -extension StringProtocol where Index == String.Index { - func ranges( - of substring: T, - options: String.CompareOptions = [], - locale: Locale? = nil - ) -> [Range] { - var ranges: [Range] = [] - while let result = range( - of: substring, - options: options, - range: (ranges.last?.upperBound ?? startIndex).. String { - self.replacingOccurrences(of: "\n", with: "") - } - - /// Removes all `space` characters in a `String` - /// - Returns: A String - func removingSpaces() -> String { - self.replacingOccurrences(of: " ", with: "") - } -} diff --git a/CodeEdit/Utils/Extensions/String/String+SHA256.swift b/CodeEdit/Utils/Extensions/String/String+SHA256.swift deleted file mode 100644 index 89c368a032..0000000000 --- a/CodeEdit/Utils/Extensions/String/String+SHA256.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// String+SHA256.swift -// CodeEditModules/CodeEditUtils -// -// Created by Debdut Karmakar on 6/9/22. -// - -import Foundation -import CryptoKit - -extension String { - - /// Returns a SHA256 encrypted String of the input String - /// - /// - Parameters: - /// - trim: If `true` the input string will be trimmed from whitespaces and new-lines. Defaults to `false`. - /// - caseSensitive: If `false` the input string will be converted to lowercase characters. Defaults to `true`. - /// - Returns: A String in HEX format - func sha256(trim: Bool = false, caseSensitive: Bool = true) -> String { - var string = self - - // trim whitespaces & new lines if specified - if trim { string = string.trimmingCharacters(in: .whitespacesAndNewlines) } - - // make string lowercased if not case sensitive - if !caseSensitive { string = string.lowercased() } - - // compute the hash - // (note that `String.data(using: .utf8)!` is safe since it will never fail) - let computed = SHA256.hash(data: string.data(using: .utf8)!) - - // map the result to a hex string and return - return computed.compactMap { String(format: "%02x", $0) }.joined() - } -} diff --git a/CodeEdit/Utils/Extensions/URL/URL+FindWorkspace.swift b/CodeEdit/Utils/Extensions/URL/URL+FindWorkspace.swift deleted file mode 100644 index a3259ef6bd..0000000000 --- a/CodeEdit/Utils/Extensions/URL/URL+FindWorkspace.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// URL+FindWorkspace.swift -// CodeEdit -// -// Created by Khan Winter on 12/19/24. -// - -import Foundation - -extension URL { - /// Finds a workspace that contains the url. - func findWorkspace() -> WorkspaceDocument? { - CodeEditDocumentController.shared.documents.first(where: { doc in - guard let workspace = doc as? WorkspaceDocument else { return false } - // createIfNotFound is safe here because it will still exit if the file and the workspace - // do not share a path prefix - return workspace.workspaceFileManager?.getFile(absolutePath, createIfNotFound: true) != nil - }) as? WorkspaceDocument - } -} diff --git a/CodeEdit/Utils/Extensions/URL/URL+absolutePath.swift b/CodeEdit/Utils/Extensions/URL/URL+absolutePath.swift deleted file mode 100644 index 8270af913f..0000000000 --- a/CodeEdit/Utils/Extensions/URL/URL+absolutePath.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// URL+LanguageServer.swift -// CodeEdit -// -// Created by Khan Winter on 9/8/24. -// - -import Foundation - -extension URL { - var absolutePath: String { - absoluteURL.path(percentEncoded: false) - } -} diff --git a/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift b/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift deleted file mode 100644 index c0d3520986..0000000000 --- a/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// URL+componentCompare.swift -// CodeEdit -// -// Created by Khan Winter on 10/22/24. -// - -import Foundation - -extension URL { - /// Compare a URL using its path components. - /// - Parameter other: The URL to compare to - /// - Returns: `true` if the URL points to the same path on disk. Regardless of query parameters, trailing - /// slashes, etc. - func componentCompare(_ other: URL) -> Bool { - return self.pathComponents == other.pathComponents - } - - /// Determines if another URL is lower in the file system than this URL. - /// - /// Examples: - /// ``` - /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/file.txt")) // true - /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/")) // false - /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/")) // false - /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/Folder")) // true - /// ``` - /// - /// - Parameter other: The URL to compare. - /// - Returns: True, if the other URL is lower in the file system. - func containsSubPath(_ other: URL) -> Bool { - other.absoluteString.starts(with: absoluteString) - && other.pathComponents.count > pathComponents.count - } - - /// Compares this url with another, counting the number of shared path components. Stops counting once a - /// different component is found. - /// - /// - Note: URL treats a leading `/` as a component, so `/Users` and `/` will return `1`. - /// - Parameter other: The URL to compare against. - /// - Returns: The number of shared components. - func sharedComponents(_ other: URL) -> Int { - var count = 0 - for (component, otherComponent) in zip(pathComponents, other.pathComponents) { - if component == otherComponent { - count += 1 - } else { - return count - } - } - return count - } -} diff --git a/CodeEdit/Utils/Extensions/View/View+focusedValue.swift b/CodeEdit/Utils/Extensions/View/View+focusedValue.swift deleted file mode 100644 index 8e46de9017..0000000000 --- a/CodeEdit/Utils/Extensions/View/View+focusedValue.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// View+focusedValue.swift -// CodeEdit -// -// Created by Wouter Hennen on 18/06/2023. -// - -import SwiftUI - -extension View { - func focusedValue( - _ keyPath: WritableKeyPath, - disabled: Bool, - _ value: Value - ) -> some View { - focusedValue(keyPath, disabled ? nil : value) - } -} diff --git a/CodeEdit/Utils/Extensions/View/View+if.swift b/CodeEdit/Utils/Extensions/View/View+if.swift deleted file mode 100644 index 1275187510..0000000000 --- a/CodeEdit/Utils/Extensions/View/View+if.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// View+if.swift -// CodeEdit -// -// Created by Khan Winter on 8/28/25. -// - -import SwiftUI - -extension View { - /// Applies the given transform if the given condition evaluates to `true`. - /// - Parameters: - /// - condition: The condition to evaluate. - /// - transform: The transform to apply to the source `View`. - /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. - @ViewBuilder - func `if`(_ condition: Bool, @ViewBuilder transform: (Self) -> Content) -> some View { - if condition { - transform(self) - } else { - self - } - } - - /// Applies the given transform if the given condition evaluates to `true`. - /// - Parameters: - /// - condition: The condition to evaluate. - /// - transform: The transform to apply to the source `View`. - /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. - @ViewBuilder - func `if`( - _ condition: Bool, - @ViewBuilder transform: (Self) -> Content, - @ViewBuilder else elseTransform: (Self) -> ElseContent - ) -> some View { - if condition { - transform(self) - } else { - elseTransform(self) - } - } -} - -extension Bool { - static var tahoe: Bool { - if #available(macOS 26, *) { - return true - } else { - return false - } - } - } diff --git a/CodeEdit/Utils/Extensions/View/View+isHovering.swift b/CodeEdit/Utils/Extensions/View/View+isHovering.swift deleted file mode 100644 index 570f574c31..0000000000 --- a/CodeEdit/Utils/Extensions/View/View+isHovering.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// View+isHovering.swift -// CodeEditModules/StatusBar -// -// Created by Lukas Pistrol on 22.03.22. -// - -import SwiftUI - -extension View { - - /// Changes the cursor appearance when hovering attached View - /// - Parameters: - /// - active: onHover() value - /// - isDragging: indicate that dragging is happening. If true this will not change the cursor. - /// - cursor: the cursor to display on hover - func isHovering(_ active: Bool, isDragging: Bool = false, cursor: NSCursor = .arrow) { - if isDragging { return } - if active { - cursor.push() - } else { - NSCursor.pop() - } - } -} diff --git a/CodeEdit/Utils/Extensions/ZipFoundation/ZipFoundation+ErrorDescrioption.swift b/CodeEdit/Utils/Extensions/ZipFoundation/ZipFoundation+ErrorDescrioption.swift deleted file mode 100644 index c9b76cb5f9..0000000000 --- a/CodeEdit/Utils/Extensions/ZipFoundation/ZipFoundation+ErrorDescrioption.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// ZipFoundation+ErrorDescrioption.swift -// CodeEdit -// -// Created by Khan Winter on 8/14/25. -// - -import Foundation -import ZIPFoundation - -extension Archive.ArchiveError: @retroactive LocalizedError { - public var errorDescription: String? { - switch self { - case .unreadableArchive: - "Unreadable archive." - case .unwritableArchive: - "Unwritable archive." - case .invalidEntryPath: - "Invalid entry path." - case .invalidCompressionMethod: - "Invalid compression method." - case .invalidCRC32: - "Invalid checksum." - case .cancelledOperation: - "Operation cancelled." - case .invalidBufferSize: - "Invalid buffer size." - case .invalidEntrySize: - "Invalid entry size." - case .invalidLocalHeaderDataOffset, - .invalidLocalHeaderSize, - .invalidCentralDirectoryOffset, - .invalidCentralDirectorySize, - .invalidCentralDirectoryEntryCount, - .missingEndOfCentralDirectoryRecord: - "Invalid file detected." - case .uncontainedSymlink: - "Uncontained symlink detected." - } - } - - public var failureReason: String? { - return switch self { - case .invalidLocalHeaderDataOffset: - "Invalid local header data offset." - case .invalidLocalHeaderSize: - "Invalid local header size." - case .invalidCentralDirectoryOffset: - "Invalid central directory offset." - case .invalidCentralDirectorySize: - "Invalid central directory size." - case .invalidCentralDirectoryEntryCount: - "Invalid central directory entry count." - case .missingEndOfCentralDirectoryRecord: - "Missing end of central directory record." - default: - nil - } - } -} diff --git a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchUIModel.swift b/CodeEdit/Utils/FuzzyMatching/FuzzyMatchUIModel.swift similarity index 84% rename from CodeEdit/Features/Search/FuzzySearch/FuzzySearchUIModel.swift rename to CodeEdit/Utils/FuzzyMatching/FuzzyMatchUIModel.swift index 5006fa58f2..40316f7495 100644 --- a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchUIModel.swift +++ b/CodeEdit/Utils/FuzzyMatching/FuzzyMatchUIModel.swift @@ -1,5 +1,5 @@ // -// FuzzySearchUIModel.swift +// FuzzyMatchUIModel.swift // CodeEdit // // Created by Khan Winter on 8/14/25. @@ -7,9 +7,11 @@ import Foundation import Combine +import AsyncAlgorithms +import CodeEditCore @MainActor -final class FuzzySearchUIModel: ObservableObject { +final class FuzzyMatchUIModel: ObservableObject { @Published var items: [Element]? private var allItems: [Element] = [] @@ -40,7 +42,7 @@ final class FuzzySearchUIModel: ObservableObject { return } - let results = await allItems.fuzzySearch(query: query) + let results = await allItems.fuzzyMatches(query: query) items = results.map { $0.item } } diff --git a/CodeEdit/Utils/KeyChain/KeychainSwiftAccessOptions.swift b/CodeEdit/Utils/KeyChain/KeychainSwiftAccessOptions.swift index d98b746364..99dc11d116 100644 --- a/CodeEdit/Utils/KeyChain/KeychainSwiftAccessOptions.swift +++ b/CodeEdit/Utils/KeyChain/KeychainSwiftAccessOptions.swift @@ -1,5 +1,5 @@ // -// CodeEditKeychainAccessOptions.swift +// KeychainSwiftAccessOptions.swift // CodeEditModules/CodeEditUtils // // Created by Nanashi Li on 2022/04/14. diff --git a/CodeEdit/Utils/Extensions/NSTableView/NSTableView+Background.swift b/CodeEdit/Utils/NSTableView+Background.swift similarity index 100% rename from CodeEdit/Utils/Extensions/NSTableView/NSTableView+Background.swift rename to CodeEdit/Utils/NSTableView+Background.swift diff --git a/CodeEdit/Features/ActivityViewer/ActivityViewer.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/ActivityViewer.swift similarity index 98% rename from CodeEdit/Features/ActivityViewer/ActivityViewer.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/ActivityViewer.swift index 8cd5053932..464c2a9582 100644 --- a/CodeEdit/Features/ActivityViewer/ActivityViewer.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/ActivityViewer.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CETerminal +import CEWorkspaceFileManager /// A view that shows the activity bar and the current status of any executed task struct ActivityViewer: View { diff --git a/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationHandler.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationHandler.swift new file mode 100644 index 0000000000..abdfdafd49 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationHandler.swift @@ -0,0 +1,99 @@ +// +// TaskNotificationHandler.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import Foundation +import Combine +import CodeEditCore + +/// Maintains the list of task notifications shown in the activity viewer. +/// +/// Listens for ``TaskNotificationEvent`` on the ``EventBus`` and creates, updates, +/// or deletes ``TaskNotificationModel`` entries accordingly. The activity viewer +/// displays only the first item in the array; use +/// ``TaskNotificationEvent/Action/createWithPriority(_:)`` to show a notification +/// immediately. +/// +/// It is recommended to use `UUID().uuidString` as the task identifier, or any +/// other unique identifier such as a token sent from a language server. Remember +/// to delete notifications when done, either manually or via +/// ``TaskNotificationEvent/Action/deleteWithDelay(id:delay:)``. +/// +/// Events can be restricted to a single workspace by passing a `workspace` URL +/// when publishing; events without one are received by all workspaces. +/// +/// ## Example +/// ```swift +/// let eventBus: EventBus // injected via the initializer +/// +/// eventBus.publish(TaskNotificationEvent( +/// .create(TaskNotificationModel(id: UUID().uuidString, title: "Indexing")) +/// )) +/// ``` +final class TaskNotificationHandler: ObservableObject { + @Published private(set) var notifications: [TaskNotificationModel] = [] + var workspaceURL: URL? + var cancellables: Set = [] + + private let eventBus: EventBus + + /// Initialises a new `TaskNotificationHandler` and starts observing for task notification events. + init(workspaceURL: URL? = nil, eventBus: EventBus) { + self.workspaceURL = workspaceURL + self.eventBus = eventBus + + eventBus.subscribe(TaskNotificationEvent.self) + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + self?.handle(event) + } + .store(in: &cancellables) + } + + /// Applies a task notification event to the notifications array. + private func handle(_ event: TaskNotificationEvent) { + // If a workspace is specified and doesn't match, don't do anything with this event. + if let workspaceURL = event.workspaceURL, workspaceURL != self.workspaceURL { + return + } + + switch event.action { + case .create(let model): + notifications.append(model) + case .createWithPriority(let model): + notifications.insert(model, at: 0) + case let .update(id, title, message, percentage, isLoading): + updateTask(id: id, title: title, message: message, percentage: percentage, isLoading: isLoading) + case .delete(let id): + notifications.removeAll { $0.id == id } + case let .deleteWithDelay(id, delay): + deleteTaskAfterDelay(taskID: id, delay: delay) + } + } + + /// Updates an existing task, applying only the non-`nil` fields. + private func updateTask(id: String, title: String?, message: String?, percentage: Double?, isLoading: Bool?) { + guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } + if let title { + notifications[index].title = title + } + if let message { + notifications[index].message = message + } + if let percentage { + notifications[index].percentage = percentage + } + if let isLoading { + notifications[index].isLoading = isLoading + } + } + + private func deleteTaskAfterDelay(taskID: String, delay: Double) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.notifications.removeAll { $0.id == taskID } + } + } +} diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationView.swift similarity index 97% rename from CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationView.swift index c73d8f229d..473ede59cc 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditCore +import CodeEditUI struct TaskNotificationView: View { @Environment(\.controlActiveState) @@ -86,5 +88,5 @@ struct TaskNotificationView: View { } #Preview { - TaskNotificationView(taskNotificationHandler: TaskNotificationHandler()) + TaskNotificationView(taskNotificationHandler: TaskNotificationHandler(eventBus: EventBus())) } diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationsDetailView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationsDetailView.swift similarity index 95% rename from CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationsDetailView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationsDetailView.swift index df417c0d25..0b6ee5ec89 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationsDetailView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/TaskNotifications/TaskNotificationsDetailView.swift @@ -5,6 +5,8 @@ // Created by Tommy Ludwig on 21.06.24. // +import CodeEditUI +import CodeEditCore import SwiftUI struct TaskNotificationsDetailView: View { @@ -44,5 +46,5 @@ struct TaskNotificationsDetailView: View { } #Preview { - TaskNotificationsDetailView(taskNotificationHandler: TaskNotificationHandler()) + TaskNotificationsDetailView(taskNotificationHandler: TaskNotificationHandler(eventBus: EventBus())) } diff --git a/CodeEdit/Features/ActivityViewer/Tasks/ActiveTaskView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/ActiveTaskView.swift similarity index 97% rename from CodeEdit/Features/ActivityViewer/Tasks/ActiveTaskView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/ActiveTaskView.swift index eab82ba5fd..3eb7b6d39e 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/ActiveTaskView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/ActiveTaskView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CETerminal // We need to observe each active task individually because: // 1. Active tasks are nested inside TaskManager. diff --git a/CodeEdit/Features/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift similarity index 98% rename from CodeEdit/Features/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift index 89226ae038..1d12f8e1d9 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI extension View { @ViewBuilder diff --git a/CodeEdit/Features/ActivityViewer/Tasks/OptionMenuItemView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/OptionMenuItemView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/OptionMenuItemView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/OptionMenuItemView.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/SchemeDropDownView.swift similarity index 98% rename from CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/SchemeDropDownView.swift index 5067871f69..729965b7d4 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/SchemeDropDownView.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CEWorkspaceFileManager +import CodeEditCore +import CodeEditUI struct SchemeDropDownView: View { @Environment(\.colorScheme) diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TaskDropDownView.swift similarity index 99% rename from CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TaskDropDownView.swift index 6ce8699311..db3dd08b8c 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TaskDropDownView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CETerminal +import CodeEditUI struct TaskDropDownView: View { @Environment(\.colorScheme) diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TaskView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TaskView.swift similarity index 93% rename from CodeEdit/Features/ActivityViewer/Tasks/TaskView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TaskView.swift index 4b0d4268b1..22ff14541b 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/TaskView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TaskView.swift @@ -6,12 +6,14 @@ // import SwiftUI +import CETerminal +import CodeEditCore /// `TaskView` represents a single active task and observes its state. /// - Parameter task: The task to be displayed and observed. /// - Parameter status: The status of the task to be displayed. struct TaskView: View { - @ObservedObject var task: CETask + let task: CETask var status: CETaskStatus var body: some View { diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TasksPopoverMenuItem.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TasksPopoverMenuItem.swift similarity index 97% rename from CodeEdit/Features/ActivityViewer/Tasks/TasksPopoverMenuItem.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TasksPopoverMenuItem.swift index 528e0c96b5..dedd7cfe3f 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/TasksPopoverMenuItem.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/TasksPopoverMenuItem.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CETerminal +import CodeEditCore /// - Note: This view **cannot** use the `dismiss` environment value to dismiss the sheet. It has to negate the boolean /// value that presented it initially. diff --git a/CodeEdit/Features/ActivityViewer/Tasks/WorkspaceMenuItemView.swift b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/WorkspaceMenuItemView.swift similarity index 95% rename from CodeEdit/Features/ActivityViewer/Tasks/WorkspaceMenuItemView.swift rename to CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/WorkspaceMenuItemView.swift index 9c12b49342..25b80add2a 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/WorkspaceMenuItemView.swift +++ b/CodeEdit/WorkspaceWindow/ActivityViewer/Tasks/WorkspaceMenuItemView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CEWorkspaceFileManager +import CodeEditCore struct WorkspaceMenuItemView: View { var workspaceFileManager: CEWorkspaceFileManager? diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift b/CodeEdit/WorkspaceWindow/CodeEditSplitViewController.swift similarity index 50% rename from CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift rename to CodeEdit/WorkspaceWindow/CodeEditSplitViewController.swift index 82b17afdee..172c14d7ac 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift +++ b/CodeEdit/WorkspaceWindow/CodeEditSplitViewController.swift @@ -6,7 +6,11 @@ // import Cocoa +import CodeEditCore +import CodeEditUI +import CEEditor import SwiftUI +import CENotifications final class CodeEditSplitViewController: NSSplitViewController { static let minSidebarWidth: CGFloat = 242 @@ -14,24 +18,48 @@ final class CodeEditSplitViewController: NSSplitViewController { static let snapWidth: CGFloat = 272 static let minSnapWidth: CGFloat = snapWidth - 10 - private weak var workspace: WorkspaceDocument? + private let dependencies: AppDependencies + + private weak var workspace: Workspace? private weak var navigatorViewModel: NavigatorAreaViewModel? private weak var windowRef: NSWindow? + private weak var statePersistence: (any WorkspaceStatePersisting)? private unowned var hapticPerformer: NSHapticFeedbackPerformer - private weak var navigatorItem: NSSplitViewItem? + // Window-UI models, owned by the window controller and injected here for the SwiftUI trees. + private let statusBarViewModel: StatusBarViewModel + private let utilityAreaModel: UtilityAreaViewModel + private let notificationPanel: NotificationPanelViewModel + + /// Per-window active-file read-model, retained so its Combine subscription lives with the window. + private var activeEditorState: AppActiveEditorState? + + /// Per-window active-cursor read-model, retained so its Combine subscription lives with the window. + private var activeCursorState: AppActiveCursorState? + + /// Per-window file-override read/write seam, retained for the window's lifetime. + private var fileEditorOverrides: AppFileEditorOverrides? // MARK: - Initialization init( - workspace: WorkspaceDocument, + workspace: Workspace, navigatorViewModel: NavigatorAreaViewModel, windowRef: NSWindow, + dependencies: AppDependencies, + statusBarViewModel: StatusBarViewModel, + utilityAreaModel: UtilityAreaViewModel, + notificationPanel: NotificationPanelViewModel, hapticPerformer: NSHapticFeedbackPerformer = NSHapticFeedbackManager.defaultPerformer ) { + self.dependencies = dependencies self.workspace = workspace self.navigatorViewModel = navigatorViewModel self.windowRef = windowRef + self.statePersistence = workspace.statePersistence + self.statusBarViewModel = statusBarViewModel + self.utilityAreaModel = utilityAreaModel + self.notificationPanel = notificationPanel self.hapticPerformer = hapticPerformer super.init(nibName: nil, bundle: nil) } @@ -49,53 +77,112 @@ final class CodeEditSplitViewController: NSSplitViewController { return } - guard let workspace, - let navigatorViewModel, - let editorManager = workspace.editorManager, - let statusBarViewModel = workspace.statusBarViewModel, - let utilityAreaModel = workspace.utilityAreaModel, - let taskManager = workspace.taskManager else { - // swiftlint:disable:next line_length - assertionFailure("Missing a workspace model: workspace=\(workspace == nil), navigator=\(navigatorViewModel == nil), editorManager=\(workspace?.editorManager == nil), statusBarModel=\(workspace?.statusBarViewModel == nil), utilityAreaModel=\(workspace?.utilityAreaModel == nil), taskManager=\(workspace?.taskManager == nil)") + guard let workspace, let navigatorViewModel else { + assertionFailure("Missing workspace=\(workspace == nil) or navigator=\(navigatorViewModel == nil)") return } splitView.translatesAutoresizingMaskIntoConstraints = false - let navigator = makeNavigator(view: SettingsInjector { - NavigatorAreaView(workspace: workspace, viewModel: navigatorViewModel) - .environmentObject(workspace) - .environmentObject(editorManager) - }) + let activeEditorState = AppActiveEditorState(editorManager: workspace.editorManager) + self.activeEditorState = activeEditorState + + let activeCursorState = AppActiveCursorState(editorManager: workspace.editorManager) + self.activeCursorState = activeCursorState + + let fileEditorOverrides = AppFileEditorOverrides(editorManager: workspace.editorManager) + self.fileEditorOverrides = fileEditorOverrides + + addSplitViewItem(makeNavigatorItem( + workspace: workspace, + navigatorViewModel: navigatorViewModel, + activeEditorState: activeEditorState + )) + addSplitViewItem(makeMainContentItem( + workspace: workspace, + windowRef: windowRef, + activeEditorState: activeEditorState, + activeCursorState: activeCursorState + )) + addSplitViewItem(makeInspectorItem( + workspace: workspace, + activeEditorState: activeEditorState, + fileEditorOverrides: fileEditorOverrides + )) + } - self.navigatorItem = navigator - addSplitViewItem(navigator) + private func makeNavigatorItem( + workspace: Workspace, + navigatorViewModel: NavigatorAreaViewModel, + activeEditorState: AppActiveEditorState + ) -> NSSplitViewItem { + makeNavigator(view: SettingsInjector(store: dependencies.settingsStore) { + NavigatorAreaView(viewModel: navigatorViewModel, navigator: dependencies.workspaceNavigator) + .environment(\.workspace, workspace) + .environmentObject(workspace.editorManager) + .environmentObject(workspace.projectNavigatorViewModel) + .environmentObject(workspace.sourceControlManager) + .environmentObject(workspace.sourceControlViewModel) + .environmentObject(workspace.searchState) + .environment(\.workspaceFileManager, workspace.workspaceFileManager) + .environment(\.workspaceFileProvider, workspace.workspaceFileManager) + .environment(\.workspaceFileURL, workspace.fileURL) + .environment(\.activeEditorState, activeEditorState) + .appServices(dependencies) + }) + } - let workspaceView = SettingsInjector { + private func makeMainContentItem( + workspace: Workspace, + windowRef: NSWindow, + activeEditorState: AppActiveEditorState, + activeCursorState: AppActiveCursorState + ) -> NSSplitViewItem { + let workspaceView = SettingsInjector(store: dependencies.settingsStore) { WindowObserver(window: WindowBox(value: windowRef)) { WorkspaceView() - .environmentObject(workspace) - .environmentObject(editorManager) + .environmentObject(workspace.editorManager) .environmentObject(statusBarViewModel) .environmentObject(utilityAreaModel) - .environmentObject(taskManager) - .environmentObject(workspace.undoRegistration) + .environmentObject(workspace.taskManager) + .environmentObject(workspace.sourceControlManager) + .environmentObject(workspace.sourceControlViewModel) + .environmentObject(workspace.undoRegistry) + .environmentObject(notificationPanel) + .environment(\.workspaceFileManager, workspace.workspaceFileManager) + .environment(\.workspaceFileProvider, workspace.workspaceFileManager) + .environment(\.workspaceFileURL, workspace.fileURL) + .environment(\.workspaceStatePersistence, workspace.statePersistence) + .environment(\.activeEditorState, activeEditorState) + .environment(\.activeCursorState, activeCursorState) + .appServices(dependencies) } } let mainContent = NSSplitViewItem(viewController: NSHostingController(rootView: workspaceView)) mainContent.titlebarSeparatorStyle = .line mainContent.minimumThickness = 200 + return mainContent + } - addSplitViewItem(mainContent) - - let inspector = makeInspector(view: SettingsInjector { - InspectorAreaView(viewModel: InspectorAreaViewModel()) - .environmentObject(workspace) - .environmentObject(editorManager) + private func makeInspectorItem( + workspace: Workspace, + activeEditorState: AppActiveEditorState, + fileEditorOverrides: AppFileEditorOverrides + ) -> NSSplitViewItem { + makeInspector(view: SettingsInjector(store: dependencies.settingsStore) { + InspectorAreaView( + viewModel: InspectorAreaViewModel(), + activeEditorState: activeEditorState + ) + .environmentObject(workspace.editorManager) + .environmentObject(workspace.sourceControlManager) + .environment(\.workspaceFileManager, workspace.workspaceFileManager) + .environment(\.workspaceFileProvider, workspace.workspaceFileManager) + .environment(\.activeEditorState, activeEditorState) + .environment(\.fileEditorOverrides, fileEditorOverrides) + .appServices(dependencies) }) - - addSplitViewItem(inspector) } private func makeNavigator(view: some View) -> NSSplitViewItem { @@ -122,25 +209,22 @@ final class CodeEditSplitViewController: NSSplitViewController { override func viewWillAppear() { super.viewWillAppear() - guard let workspace else { return } - workspace.notificationPanel.updateToolbarItem() - - let navigatorWidth = workspace.getFromWorkspaceState(.splitViewWidth) as? CGFloat + let navigatorWidth = statePersistence?.get(.splitViewWidth) as? CGFloat splitView.setPosition(navigatorWidth ?? Self.minSidebarWidth, ofDividerAt: 0) if let firstSplitView = splitViewItems.first { - firstSplitView.isCollapsed = workspace.getFromWorkspaceState( + firstSplitView.isCollapsed = statePersistence?.get( .navigatorCollapsed ) as? Bool ?? false } if let lastSplitView = splitViewItems.last { - lastSplitView.isCollapsed = workspace.getFromWorkspaceState( + lastSplitView.isCollapsed = statePersistence?.get( .inspectorCollapsed ) as? Bool ?? true } - workspace.notificationPanel.updateToolbarItem() + notificationPanel.updateToolbarItem() } // MARK: - NSSplitViewDelegate @@ -213,16 +297,16 @@ final class CodeEditSplitViewController: NSSplitViewController { width = panel.frame.size.width } if width > 0 { - workspace?.addToWorkspaceState(key: .splitViewWidth, value: width) + statePersistence?.set(key: .splitViewWidth, value: width) } } } func saveNavigatorCollapsedState(isCollapsed: Bool) { - workspace?.addToWorkspaceState(key: .navigatorCollapsed, value: isCollapsed) + statePersistence?.set(key: .navigatorCollapsed, value: isCollapsed) } func saveInspectorCollapsedState(isCollapsed: Bool) { - workspace?.addToWorkspaceState(key: .inspectorCollapsed, value: isCollapsed) + statePersistence?.set(key: .inspectorCollapsed, value: isCollapsed) } } diff --git a/CodeEdit/WorkspaceWindow/CodeEditWindowController+Commands.swift b/CodeEdit/WorkspaceWindow/CodeEditWindowController+Commands.swift new file mode 100644 index 0000000000..7871760f8a --- /dev/null +++ b/CodeEdit/WorkspaceWindow/CodeEditWindowController+Commands.swift @@ -0,0 +1,35 @@ +// +// CodeEditWindowController+Commands.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 31/07/26. +// + +import SwiftUI + +extension CodeEditWindowController { + /// These are example items that added as commands to command palette + func registerCommands() { + let commandManager = dependencies.commandManager + commandManager.addCommand( + name: "Quick Open", + title: "Quick Open", + id: "quick_open", + command: { [weak self] in self?.openQuickly(nil) } + ) + + commandManager.addCommand( + name: "Toggle Navigator", + title: "Toggle Navigator", + id: "toggle_left_sidebar", + command: { [weak self] in self?.toggleFirstPanel() } + ) + + commandManager.addCommand( + name: "Toggle Inspector", + title: "Toggle Inspector", + id: "toggle_right_sidebar", + command: { [weak self] in self?.toggleLastPanel() } + ) + } +} diff --git a/CodeEdit/WorkspaceWindow/CodeEditWindowController+DocumentEditedState.swift b/CodeEdit/WorkspaceWindow/CodeEditWindowController+DocumentEditedState.swift new file mode 100644 index 0000000000..2515311ade --- /dev/null +++ b/CodeEdit/WorkspaceWindow/CodeEditWindowController+DocumentEditedState.swift @@ -0,0 +1,59 @@ +// +// CodeEditWindowController+DocumentEditedState.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 31/07/26. +// + +import SwiftUI +import Combine + +extension CodeEditWindowController { + // Listen to changes in all tabs/files + internal func listenToDocumentEdited(workspace: Workspace) { + let editorManager = workspace.editorManager + editorManager.$activeEditor + .flatMap({ editor in + editor.$tabs + }) + .compactMap({ tab in + Publishers.MergeMany(tab.elements.map({ editorManager.documentPublisher(for: $0.file) })) + }) + .switchToLatest() + .compactMap({ fileDocument in + fileDocument?.isDocumentEditedPublisher + }) + .flatMap({ $0 }) + .sink { isDocumentEdited in + if isDocumentEdited { + self.setDocumentEdited(true) + return + } + + self.updateDocumentEdited(workspace: workspace) + } + .store(in: &cancellables) + + // Listen to change of tabs, if closed tab without saving content, + // we also need to recalculate isDocumentEdited + editorManager.$activeEditor + .flatMap({ editor in + editor.$tabs + }) + .sink { _ in + self.updateDocumentEdited(workspace: workspace) + } + .store(in: &cancellables) + } + + // Recalculate documentEdited by checking if any tab/file is edited + private func updateDocumentEdited(workspace: Workspace) { + let editorManager = workspace.editorManager + let hasEditedDocuments = !editorManager + .editorLayout + .gatherOpenFiles() + .filter({ editorManager.document(for: $0)?.isDocumentEdited == true }) + .isEmpty + self.setDocumentEdited(hasEditedDocuments) + } +} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift b/CodeEdit/WorkspaceWindow/CodeEditWindowController+Panels.swift similarity index 97% rename from CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift rename to CodeEdit/WorkspaceWindow/CodeEditWindowController+Panels.swift index ea9feebb2e..4fb79852d7 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift +++ b/CodeEdit/WorkspaceWindow/CodeEditWindowController+Panels.swift @@ -79,10 +79,10 @@ extension CodeEditWindowController { toggle: { self.toggleLastPanel(shouldAnimate: false) } ), PanelDescriptor( - isCollapsed: { self.workspace?.utilityAreaModel?.isCollapsed ?? true }, + isCollapsed: { self.utilityAreaModel.isCollapsed }, getPrevCollapsed: { self.prevUtilityAreaCollapsed }, setPrevCollapsed: { self.prevUtilityAreaCollapsed = $0 }, - toggle: { self.workspace?.utilityAreaModel?.togglePanel(animation: false) } + toggle: { self.utilityAreaModel.togglePanel(animation: false) } ), PanelDescriptor( isCollapsed: { self.toolbarCollapsed }, diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/WorkspaceWindow/CodeEditWindowController+Toolbar.swift similarity index 72% rename from CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift rename to CodeEdit/WorkspaceWindow/CodeEditWindowController+Toolbar.swift index fbb536a074..1995c06239 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/WorkspaceWindow/CodeEditWindowController+Toolbar.swift @@ -6,8 +6,11 @@ // import AppKit +import CESourceControl +import CEWorkspaceFileManager import SwiftUI import Combine +import CENotifications extension CodeEditWindowController { internal func setupToolbar() { @@ -101,14 +104,14 @@ extension CodeEditWindowController { func toggleToolbar() { toolbarCollapsed.toggle() - workspace?.addToWorkspaceState(key: .toolbarCollapsed, value: toolbarCollapsed) + workspace?.statePersistence.set(key: .toolbarCollapsed, value: toolbarCollapsed) updateToolbarVisibility() } func updateToolbarVisibility() { if toolbarCollapsed { window?.titleVisibility = .visible - window?.title = workspace?.workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty" + window?.title = workspace?.workspaceFileManager.folderUrl.lastPathComponent ?? "Empty" window?.toolbar = nil } else { window?.titleVisibility = .hidden @@ -164,9 +167,12 @@ extension CodeEditWindowController { case .branchPicker: let toolbarItem = NSToolbarItem(itemIdentifier: .branchPicker) let view = NSHostingView( - rootView: ToolbarBranchPicker( - workspaceFileManager: workspace?.workspaceFileManager - ) + rootView: SettingsInjector(store: dependencies.settingsStore) { + ToolbarBranchPicker( + fallbackTitle: workspace?.workspaceFileManager.folderUrl.lastPathComponent ?? "Empty", + sourceControlManager: workspace?.sourceControlManager + ) + } ) toolbarItem.view = view toolbarItem.isBordered = false @@ -179,11 +185,15 @@ extension CodeEditWindowController { guard #available(macOS 26, *) else { fatalError("Unified task sidebar item used on pre-tahoe platform.") } - guard let workspace, - let stop = StopTaskToolbarItem(workspace: workspace) else { + guard let workspace else { return nil } - let start = StartTaskToolbarItem(workspace: workspace) + let stop = StopTaskToolbarItem(workspace: workspace) + let start = StartTaskToolbarItem( + workspace: workspace, + utilityAreaModel: utilityAreaModel, + commandManager: dependencies.commandManager + ) let group = NSToolbarItemGroup(itemIdentifier: .taskSidebarItem) group.isBordered = true @@ -202,8 +212,12 @@ extension CodeEditWindowController { guard let taskManager = workspace?.taskManager else { return nil } + // Wrapped like every other standalone hosting root in this file: `@Environment` does not + // cross the boundary, so without it this subtree reads settings defaults and discards writes. let view = NSHostingView( - rootView: StopTaskToolbarButton(taskManager: taskManager) + rootView: SettingsInjector(store: dependencies.settingsStore) { + StopTaskToolbarButton(taskManager: taskManager) + } ) toolbarItem.view = view @@ -214,11 +228,12 @@ extension CodeEditWindowController { let toolbarItem = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier.startTaskSidebarItem) guard let taskManager = workspace?.taskManager else { return nil } - guard let workspace = workspace else { return nil } let view = NSHostingView( - rootView: StartTaskToolbarButton(taskManager: taskManager) - .environmentObject(workspace) + rootView: SettingsInjector(store: dependencies.settingsStore) { + StartTaskToolbarButton(taskManager: taskManager) + .environmentObject(utilityAreaModel) + } ) toolbarItem.view = view @@ -228,7 +243,11 @@ extension CodeEditWindowController { private func notificationItem() -> NSToolbarItem? { let toolbarItem = NSToolbarItem(itemIdentifier: .notificationItem) guard let workspace = workspace else { return nil } - let view = NSHostingView(rootView: NotificationToolbarItem().environmentObject(workspace)) + let view = NSHostingView( + rootView: SettingsInjector(store: dependencies.settingsStore) { + NotificationToolbarItem().environmentObject(notificationPanel) + } + ) toolbarItem.view = view return toolbarItem } @@ -237,17 +256,18 @@ extension CodeEditWindowController { let toolbarItem = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier.activityViewer) toolbarItem.visibilityPriority = .user guard let workspaceSettingsManager = workspace?.workspaceSettingsManager, - let taskNotificationHandler = workspace?.taskNotificationHandler, let taskManager = workspace?.taskManager else { return nil } let view = NSHostingView( - rootView: ActivityViewer( - workspaceFileManager: workspace?.workspaceFileManager, - workspaceSettingsManager: workspaceSettingsManager, - taskNotificationHandler: taskNotificationHandler, - taskManager: taskManager - ) + rootView: SettingsInjector(store: dependencies.settingsStore) { + ActivityViewer( + workspaceFileManager: workspace?.workspaceFileManager, + workspaceSettingsManager: workspaceSettingsManager, + taskNotificationHandler: taskNotificationHandler, + taskManager: taskManager + ) + } ) let weakWidth = view.widthAnchor.constraint(equalToConstant: 650) @@ -264,3 +284,16 @@ extension CodeEditWindowController { return toolbarItem } } + +extension NSToolbarItem.Identifier { + static let toggleFirstSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ToggleFirstSidebarItem") + static let toggleLastSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ToggleLastSidebarItem") + static let stopTaskSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("StopTaskSidebarItem") + static let startTaskSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("StartTaskSidebarItem") + static let itemListTrackingSeparator = NSToolbarItem.Identifier("ItemListTrackingSeparator") + static let branchPicker: NSToolbarItem.Identifier = NSToolbarItem.Identifier("BranchPicker") + static let activityViewer: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ActivityViewer") + static let notificationItem = NSToolbarItem.Identifier("notificationItem") + + static let taskSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("TaskSidebarItem") +} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/WorkspaceWindow/CodeEditWindowController.swift similarity index 55% rename from CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift rename to CodeEdit/WorkspaceWindow/CodeEditWindowController.swift index be082d6fee..329ce4cb5d 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/WorkspaceWindow/CodeEditWindowController.swift @@ -6,7 +6,12 @@ // import Cocoa +import CodeEditDocument +import CodeEditSettings +import CEEditor +import CENotifications import SwiftUI +import CodeEditUI import Combine final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, ObservableObject, NSWindowDelegate { @@ -24,12 +29,24 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs var observers: [NSKeyValueObservation] = [] - var workspace: WorkspaceDocument? + let dependencies: AppDependencies + + var workspace: Workspace? var workspaceSettingsWindow: NSWindow? var quickOpenPanel: SearchPanel? var commandPalettePanel: SearchPanel? var navigatorSidebarViewModel: NavigatorAreaViewModel? + // Window-UI models: window-scoped state, owned here (1:1 with the workspace). + let statusBarViewModel = StatusBarViewModel() + let utilityAreaModel = UtilityAreaViewModel( + tabItems: utilityAreaContributions(extensionManager: .shared) + ) + let openQuicklyViewModel: OpenQuicklyViewModel + let commandsPaletteState: QuickActionsViewModel + let notificationPanel: NotificationPanelViewModel + let taskNotificationHandler: TaskNotificationHandler + internal var cancellables = [AnyCancellable]() var splitViewController: CodeEditSplitViewController? { @@ -38,13 +55,25 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs init( window: NSWindow?, - workspace: WorkspaceDocument? + workspace: Workspace, + dependencies: AppDependencies ) { + self.dependencies = dependencies + self.workspace = workspace + self.openQuicklyViewModel = OpenQuicklyViewModel(fileURL: workspace.fileURL) + self.commandsPaletteState = QuickActionsViewModel(commandManager: dependencies.commandManager) + self.notificationPanel = NotificationPanelViewModel( + notificationManager: dependencies.notificationManager, + eventBus: dependencies.eventBus + ) + self.taskNotificationHandler = TaskNotificationHandler( + workspaceURL: workspace.fileURL, + eventBus: dependencies.eventBus + ) super.init(window: window) window?.delegate = self - guard let workspace else { return } - self.workspace = workspace - self.toolbarCollapsed = workspace.getFromWorkspaceState(.toolbarCollapsed) as? Bool ?? false + self.toolbarCollapsed = workspace.statePersistence.get(.toolbarCollapsed) as? Bool ?? false + utilityAreaModel.restoreFromState(workspace.statePersistence) guard let splitViewController = setupSplitView(with: workspace) else { fatalError("Failed to set up content view.") } @@ -88,7 +117,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs fatalError("init(coder:) has not been implemented") } - private func setupSplitView(with workspace: WorkspaceDocument) -> CodeEditSplitViewController? { + private func setupSplitView(with workspace: Workspace) -> CodeEditSplitViewController? { guard let window else { assertionFailure("No window found for this controller. Cannot set up content.") return nil @@ -100,22 +129,29 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs return CodeEditSplitViewController( workspace: workspace, navigatorViewModel: navigatorModel, - windowRef: window + windowRef: window, + dependencies: dependencies, + statusBarViewModel: statusBarViewModel, + utilityAreaModel: utilityAreaModel, + notificationPanel: notificationPanel ) } private func getSelectedCodeFile() -> CodeFileDocument? { - workspace?.editorManager?.activeEditor.selectedTab?.file.fileDocument + guard let editorManager = workspace?.editorManager, + let file = editorManager.activeEditor.selectedTab?.file else { return nil } + return editorManager.document(for: file) } @IBAction func saveDocument(_ sender: Any) { guard let codeFile = getSelectedCodeFile() else { return } codeFile.save(sender) - workspace?.editorManager?.activeEditor.temporaryTab = nil + workspace?.editorManager.activeEditor.temporaryTab = nil } @IBAction func openCommandPalette(_ sender: Any) { - if let workspace, let state = workspace.commandsPaletteState { + do { + let state = commandsPaletteState if let commandPalettePanel { if commandPalettePanel.isKeyWindow { commandPalettePanel.close() @@ -135,7 +171,9 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs panel.close() self.panelOpen = false } - panel.contentView = NSHostingView(rootView: SettingsInjector { contentView }) + panel.contentView = NSHostingView( + rootView: SettingsInjector(store: dependencies.settingsStore) { contentView } + ) window?.addChildWindow(panel, ordered: .above) panel.makeKeyAndOrderFront(self) self.panelOpen = true @@ -150,16 +188,17 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs } if let navigatorViewModel = navigatorSidebarViewModel, - let searchTab = navigatorViewModel.tabItems.first(where: { $0 == .search }) { + navigatorViewModel.tabItems.contains(where: { $0.id == PanelTabID.search }) { DispatchQueue.main.async { - self.workspace?.searchState?.shouldFocusSearchField = true - navigatorViewModel.setNavigatorTab(tab: searchTab) + self.workspace?.searchState.shouldFocusSearchField = true + navigatorViewModel.selectedTabID = PanelTabID.search } } } @IBAction func openQuickly(_ sender: Any?) { - if let workspace, let state = workspace.openQuicklyViewModel { + if let workspace { + let state = openQuicklyViewModel if let quickOpenPanel { if quickOpenPanel.isKeyWindow { quickOpenPanel.close() @@ -178,10 +217,17 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs panel.close() self.panelOpen = false } openFile: { file in - workspace.editorManager?.openTab(item: file) - }.environmentObject(workspace) + workspace.editorManager.openTab(item: file) + } + .environment(\.workspaceFileManager, workspace.workspaceFileManager) + .environment(\.workspaceFileProvider, workspace.workspaceFileManager) + .environment(\.filePreview) { file in AnyView(FilePreviewView(item: file)) } + .environment(\.languageServices, dependencies.languageServicesProvider) + .environmentObject(ThemeModel.shared.activeTheme) - panel.contentView = NSHostingView(rootView: SettingsInjector { contentView }) + panel.contentView = NSHostingView( + rootView: SettingsInjector(store: dependencies.settingsStore) { contentView } + ) window?.addChildWindow(panel, ordered: .above) panel.makeKeyAndOrderFront(self) self.panelOpen = true @@ -191,24 +237,63 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs @IBAction func closeCurrentTab(_ sender: Any) { if self.panelOpen { return } - if (workspace?.editorManager?.activeEditor.tabs ?? []).isEmpty { + if (workspace?.editorManager.activeEditor.tabs ?? []).isEmpty { self.closeActiveEditor(self) } else { - workspace?.editorManager?.activeEditor.closeSelectedTab() + workspace?.editorManager.activeEditor.closeSelectedTab() } } @IBAction func closeActiveEditor(_ sender: Any) { - if workspace?.editorManager?.editorLayout.findSomeEditor( - except: workspace?.editorManager?.activeEditor + if workspace?.editorManager.editorLayout.findSomeEditor( + except: workspace?.editorManager.activeEditor ) == nil { NSApp.sendAction(#selector(NSWindow.performClose(_:)), to: NSApp.keyWindow, from: nil) } else { - workspace?.editorManager?.activeEditor.close() + workspace?.editorManager.activeEditor.close() + } + } + + @IBAction func openWorkspaceSettings(_ sender: Any) { + guard let window = window, + let workspace = workspace + else { return } + let workspaceSettingsManager = workspace.workspaceSettingsManager + let taskManager = workspace.taskManager + + if let workspaceSettingsWindow, workspaceSettingsWindow.isVisible { + workspaceSettingsWindow.makeKeyAndOrderFront(self) + } else { + let settingsWindow = NSWindow() + self.workspaceSettingsWindow = settingsWindow + let contentView = WorkspaceSettingsView( + dismiss: { [weak self, weak settingsWindow] in + guard let settingsWindow else { return } + self?.window?.endSheet(settingsWindow) + } + ) + .environmentObject(workspaceSettingsManager) + .environmentObject(taskManager) + + settingsWindow.contentView = NSHostingView( + rootView: SettingsInjector(store: dependencies.settingsStore) { contentView } + ) + settingsWindow.titlebarAppearsTransparent = true + settingsWindow.setContentSize(NSSize(width: 515, height: 515)) + settingsWindow.setAccessibilityTitle("Workspace Settings") + + window.beginSheet(settingsWindow, completionHandler: nil) } } func windowShouldClose(_ sender: NSWindow) -> Bool { + // Check for unsaved changes before closing + if let workspace, workspace.hasUnsavedChanges() { + guard workspace.promptSaveUnsavedFiles() else { + return false // User cancelled + } + } + cancellables.forEach({ $0.cancel() }) cancellables.removeAll() @@ -223,6 +308,12 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs quickOpenPanel = nil commandPalettePanel = nil navigatorSidebarViewModel = nil + + // Notify the window manager to clean up workspace state + if let workspace { + utilityAreaModel.saveRestorationState(workspace.statePersistence) + dependencies.workspaceWindowManager.closeWorkspace(workspace) + } workspace = nil return true } diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/WorkspaceWindow/InspectorArea/FileInspectorView.swift similarity index 66% rename from CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/FileInspectorView.swift index 8a00ccb4b4..7ad4035a62 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/WorkspaceWindow/InspectorArea/FileInspectorView.swift @@ -5,12 +5,21 @@ // Created by Nanashi Li on 2022/03/24. // import SwiftUI +import CodeEditSettings +import CodeEditCore import CodeEditLanguages struct FileInspectorView: View { - @EnvironmentObject private var workspace: WorkspaceDocument + @Environment(\.activeEditorState) + private var activeEditorState - @EnvironmentObject private var editorManager: EditorManager + @Environment(\.fileEditorOverrides) + private var fileEditorOverrides + @Environment(\.fileRelocator) + private var fileRelocator + + @AppSettings(\.general) + private var generalSettings @AppSettings(\.textEditing) private var textEditing @@ -21,25 +30,26 @@ struct FileInspectorView: View { // File settings overrides - @State private var language: CodeLanguage? + @State private var languageId: String? - @State var indentOption: SettingsData.TextEditingSettings.IndentOption = .init(indentType: .tab) + @State var indentOption: TextEditingSettings.IndentOption = .init(indentType: .tab) @State var defaultTabWidth: Int = 0 @State var wrapLines: Bool = false - func updateFileOptions(_ textEditingOverride: SettingsData.TextEditingSettings? = nil) { + func updateFileOptions(_ textEditingOverride: TextEditingSettings? = nil) { let textEditingSettings = textEditingOverride ?? textEditing - indentOption = file?.fileDocument?.indentOption ?? textEditingSettings.indentOption - defaultTabWidth = file?.fileDocument?.defaultTabWidth ?? textEditingSettings.defaultTabWidth - wrapLines = file?.fileDocument?.wrapLines ?? textEditingSettings.wrapLinesToEditorWidth + let values = file.map { fileEditorOverrides.overrides(for: $0) } + indentOption = values?.indentOption ?? textEditingSettings.indentOption + defaultTabWidth = values?.defaultTabWidth ?? textEditingSettings.defaultTabWidth + wrapLines = values?.wrapLines ?? textEditingSettings.wrapLinesToEditorWidth } func updateInspectorSource() { - file = editorManager.activeEditor.selectedTab?.file + file = activeEditorState.selectedFile fileName = file?.name ?? "" - language = file?.fileDocument?.language + languageId = file.flatMap { fileEditorOverrides.overrides(for: $0).languageId } updateFileOptions() } @@ -67,13 +77,7 @@ struct FileInspectorView: View { .onAppear { updateInspectorSource() } - .onReceive(editorManager.activeEditor.objectWillChange) { _ in - updateInspectorSource() - } - .onChange(of: editorManager.activeEditor) { _, _ in - updateInspectorSource() - } - .onChange(of: editorManager.activeEditor.selectedTab) { _, _ in + .onReceive(activeEditorState.selectedFilePublisher) { _ in updateInspectorSource() } .onChange(of: textEditing) { _, newValue in @@ -85,23 +89,18 @@ struct FileInspectorView: View { if let file { TextField("Name", text: $fileName) .background( - fileName != file.fileName() && !file.validateFileName(for: fileName) ? Color(errorRed) : Color.clear + fileName != file.fileName() + && !file.validateFileName(for: fileName, prefs: generalSettings) + ? Color(errorRed) : Color.clear ) .onSubmit { - if file.validateFileName(for: fileName) { + if file.validateFileName(for: fileName, prefs: generalSettings) { let destinationURL = file.url .deletingLastPathComponent() .appending(path: fileName) - DispatchQueue.main.async { [weak workspace] in + DispatchQueue.main.async { do { - if let newItem = try workspace?.workspaceFileManager?.move( - file: file, - to: destinationURL - ), - !newItem.isFolder { - editorManager.editorLayout.closeAllTabs(of: file) - editorManager.openTab(item: newItem) - } + _ = try fileRelocator.relocate(file: file, to: destinationURL) } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") @@ -109,7 +108,7 @@ struct FileInspectorView: View { } } } else { - fileName = file.labelFileName() + fileName = file.labelFileName(generalSettings) } } } @@ -118,16 +117,18 @@ struct FileInspectorView: View { @ViewBuilder private var fileType: some View { Picker( "Type", - selection: $language + selection: $languageId ) { - Text("Default - Detected").tag(nil as CodeLanguage?) + Text("Default - Detected").tag(nil as String?) Divider() ForEach(CodeLanguage.allLanguages, id: \.id) { language in - Text(language.id.rawValue.capitalized).tag(language as CodeLanguage?) + Text(language.id.rawValue.capitalized).tag(language.id.rawValue as String?) } } - .onChange(of: language) { _, newValue in - file?.fileDocument?.language = newValue + .onChange(of: languageId) { _, newValue in + if let file { + fileEditorOverrides.setLanguageId(newValue, for: file) + } } } @@ -141,14 +142,9 @@ struct FileInspectorView: View { } // This is ugly but if the tab is opened at the same time as closing the others, it doesn't open // And if the files are re-built at the same time as the tab is opened, it causes a memory error - DispatchQueue.main.async { [weak workspace] in + DispatchQueue.main.async { do { - guard let newItem = try workspace?.workspaceFileManager?.move(file: file, to: newURL), - !newItem.isFolder else { - return - } - editorManager.editorLayout.closeAllTabs(of: file) - editorManager.openTab(item: newItem) + _ = try fileRelocator.relocate(file: file, to: newURL) } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") @@ -168,11 +164,13 @@ struct FileInspectorView: View { private var indentUsing: some View { Picker("Indent using", selection: $indentOption.indentType) { - Text("Spaces").tag(SettingsData.TextEditingSettings.IndentOption.IndentType.spaces) - Text("Tabs").tag(SettingsData.TextEditingSettings.IndentOption.IndentType.tab) + Text("Spaces").tag(TextEditingSettings.IndentOption.IndentType.spaces) + Text("Tabs").tag(TextEditingSettings.IndentOption.IndentType.tab) } .onChange(of: indentOption) { _, newValue in - file?.fileDocument?.indentOption = newValue == textEditing.indentOption ? nil : newValue + if let file { + fileEditorOverrides.setIndentOption(newValue == textEditing.indentOption ? nil : newValue, for: file) + } } } @@ -216,14 +214,24 @@ struct FileInspectorView: View { } } .onChange(of: defaultTabWidth) { _, newValue in - file?.fileDocument?.defaultTabWidth = newValue == textEditing.defaultTabWidth ? nil : newValue + if let file { + fileEditorOverrides.setDefaultTabWidth( + newValue == textEditing.defaultTabWidth ? nil : newValue, + for: file + ) + } } } private var wrapLinesToggle: some View { Toggle("Wrap lines", isOn: $wrapLines) .onChange(of: wrapLines) { _, newValue in - file?.fileDocument?.wrapLines = newValue == textEditing.wrapLinesToEditorWidth ? nil : newValue + if let file { + fileEditorOverrides.setWrapLines( + newValue == textEditing.wrapLinesToEditorWidth ? nil : newValue, + for: file + ) + } } } diff --git a/CodeEdit/WorkspaceWindow/InspectorArea/InspectorAreaView.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorAreaView.swift new file mode 100644 index 0000000000..0b9fee303c --- /dev/null +++ b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorAreaView.swift @@ -0,0 +1,62 @@ +// +// InspectorAreaView.swift +// CodeEdit +// +// Created by Austin Condiff on 3/21/22. +// + +import SwiftUI +import CodeEditCore +import CodeEditSettings + +struct InspectorAreaView: View { + @ObservedObject private var extensionManager = ExtensionManager.shared + @ObservedObject public var viewModel: InspectorAreaViewModel + + @AppSettings(\.general.inspectorTabBarPosition) + var sidebarPosition: GeneralSettings.SidebarTabBarPosition + + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + + /// The active-file read-model the history inspector follows. Taken by `init` rather than read + /// from `\.activeEditorState`: `updateTabs()` builds the contributions, and a view's + /// environment is not populated at the point the tabs first need it. + private let activeEditorState: ActiveEditorState + + init(viewModel: InspectorAreaViewModel, activeEditorState: ActiveEditorState) { + self.viewModel = viewModel + self.activeEditorState = activeEditorState + } + + private func updateTabs() { + viewModel.tabItems = inspectorContributions( + extensionManager: extensionManager, + showInternalDevelopment: showInternalDevelopmentInspector, + activeEditorState: activeEditorState + ) + } + + var body: some View { + WorkspacePanelView( + viewModel: viewModel, + selectedTabID: $viewModel.selectedTabID, + tabItems: $viewModel.tabItems, + sidebarPosition: sidebarPosition, + sideOnTrailing: true + ) + .formStyle(.grouped) + .accessibilityElement(children: .contain) + .accessibilityLabel("inspector") + // Seeded here, not in `init`: `showInternalDevelopmentInspector` reads the environment, + // which SwiftUI only populates once the view is in the hierarchy. Called from `init` it + // silently returned the section default — invisible while settings came from a singleton, + // and a hard trap now that they come from the environment. + .onAppear { + updateTabs() + } + .onChange(of: showInternalDevelopmentInspector) { _, _ in + updateTabs() + } + } +} diff --git a/CodeEdit/Features/InspectorArea/ViewModels/InspectorAreaViewModel.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorAreaViewModel.swift similarity index 51% rename from CodeEdit/Features/InspectorArea/ViewModels/InspectorAreaViewModel.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/InspectorAreaViewModel.swift index a36f8f0490..013a0648a9 100644 --- a/CodeEdit/Features/InspectorArea/ViewModels/InspectorAreaViewModel.swift +++ b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorAreaViewModel.swift @@ -6,13 +6,10 @@ // import Foundation +import CodeEditUI class InspectorAreaViewModel: ObservableObject { - @Published var selectedTab: InspectorTab? = .file + @Published var selectedTabID: String? = PanelTabID.file /// The tab bar items in the Inspector - @Published var tabItems: [InspectorTab] = [] - - func setInspectorTab(tab newTab: InspectorTab) { - selectedTab = newTab - } + @Published var tabItems: [any WorkspacePanelContribution] = [] } diff --git a/CodeEdit/WorkspaceWindow/InspectorArea/InspectorContributions.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorContributions.swift new file mode 100644 index 0000000000..872f4ac06d --- /dev/null +++ b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorContributions.swift @@ -0,0 +1,23 @@ +// +// InspectorContributions.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import CodeEditUI +import SwiftUI + +struct FileInspectorContribution: WorkspacePanelContribution { + let id = PanelTabID.file + let title = "File Inspector" + let systemImage = "doc" + var content: AnyView { AnyView(FileInspectorView()) } +} + +struct InternalDevelopmentInspectorContribution: WorkspacePanelContribution { + let id = PanelTabID.internalDevelopment + let title = "Internal Development" + let systemImage = "hammer" + var content: AnyView { AnyView(InternalDevelopmentInspectorView()) } +} diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorField.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorField.swift similarity index 100% rename from CodeEdit/Features/InspectorArea/Views/InspectorField.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/InspectorField.swift diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorSection.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InspectorSection.swift similarity index 100% rename from CodeEdit/Features/InspectorArea/Views/InspectorSection.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/InspectorSection.swift diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift similarity index 100% rename from CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift similarity index 96% rename from CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift index 5334880996..ef100dd74c 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift +++ b/CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift @@ -6,8 +6,12 @@ // import SwiftUI +import CENotifications struct InternalDevelopmentNotificationsView: View { + @Environment(\.notificationManager) + private var notificationManager + enum IconType: String, CaseIterable { case symbol = "Symbol" case image = "Image" @@ -129,7 +133,7 @@ struct InternalDevelopmentNotificationsView: View { let iconSymbol = selectedSymbol ?? availableSymbols.randomElement() ?? "bell.fill" let iconColor = selectedColor ?? availableColors.randomElement()?.1 ?? .blue - NotificationManager.shared.post( + notificationManager?.post( iconSymbol: iconSymbol, iconColor: iconColor, title: notificationTitle, @@ -143,7 +147,7 @@ struct InternalDevelopmentNotificationsView: View { case .image: let imageName = selectedImage ?? availableImages.randomElement() ?? "GitHubIcon" - NotificationManager.shared.post( + notificationManager?.post( iconImage: Image(imageName), title: notificationTitle, description: notificationDescription, @@ -157,7 +161,7 @@ struct InternalDevelopmentNotificationsView: View { let text = selectedText ?? randomLetter() let iconColor = selectedColor ?? availableColors.randomElement()?.1 ?? .blue - NotificationManager.shared.post( + notificationManager?.post( iconText: text, iconTextColor: .white, iconColor: iconColor, @@ -173,7 +177,7 @@ struct InternalDevelopmentNotificationsView: View { let emoji = selectedEmoji ?? availableEmojis.randomElement() ?? "🔔" let iconColor = selectedColor ?? availableColors.randomElement()?.1 ?? .blue - NotificationManager.shared.post( + notificationManager?.post( iconText: emoji, iconTextColor: .white, iconColor: iconColor, diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift b/CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift similarity index 100% rename from CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift diff --git a/CodeEdit/Features/InspectorArea/Views/NoSelectionInspectorView.swift b/CodeEdit/WorkspaceWindow/InspectorArea/NoSelectionInspectorView.swift similarity index 79% rename from CodeEdit/Features/InspectorArea/Views/NoSelectionInspectorView.swift rename to CodeEdit/WorkspaceWindow/InspectorArea/NoSelectionInspectorView.swift index 6e5d4f7ad3..1b9feba944 100644 --- a/CodeEdit/Features/InspectorArea/Views/NoSelectionInspectorView.swift +++ b/CodeEdit/WorkspaceWindow/InspectorArea/NoSelectionInspectorView.swift @@ -1,11 +1,12 @@ // -// NoSelectionView.swift +// NoSelectionInspectorView.swift // CodeEdit // // Created by Nanashi Li on 2022/04/18. // import SwiftUI +import CodeEditUI struct NoSelectionInspectorView: View { var body: some View { diff --git a/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorAreaView.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorAreaView.swift new file mode 100644 index 0000000000..f4164ea32a --- /dev/null +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorAreaView.swift @@ -0,0 +1,46 @@ +// +// NavigatorAreaView.swift +// CodeEdit +// +// Created by Lukas Pistrol on 17.03.22. +// + +import SwiftUI +import CodeEditCore +import CodeEditSettings + +struct NavigatorAreaView: View { + @ObservedObject private var extensionManager = ExtensionManager.shared + @ObservedObject public var viewModel: NavigatorAreaViewModel + + @AppSettings(\.general.navigatorTabBarPosition) + var sidebarPosition: GeneralSettings.SidebarTabBarPosition + + init(viewModel: NavigatorAreaViewModel, navigator: WorkspaceNavigator) { + self.viewModel = viewModel + + viewModel.tabItems = navigatorContributions( + extensionManager: extensionManager, + navigator: navigator + ) + } + + var body: some View { + WorkspacePanelView( + viewModel: viewModel, + selectedTabID: $viewModel.selectedTabID, + tabItems: $viewModel.tabItems, + sidebarPosition: sidebarPosition, + sidebarPadding: { + if sidebarPosition == .side { + return (.trailing, 8) + } + + return ([], 0) + } + ) + .listStyle(.inset) + .accessibilityElement(children: .contain) + .accessibilityLabel("navigator") + } +} diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/NavigatorAreaViewModel.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorAreaViewModel.swift similarity index 51% rename from CodeEdit/Features/NavigatorArea/ViewModels/NavigatorAreaViewModel.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorAreaViewModel.swift index 0133d81414..f546edeba3 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/NavigatorAreaViewModel.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorAreaViewModel.swift @@ -6,13 +6,10 @@ // import Foundation +import CodeEditUI class NavigatorAreaViewModel: ObservableObject { - @Published var selectedTab: NavigatorTab? = .project + @Published var selectedTabID: String? = PanelTabID.project /// The tab bar items in the Navigator - @Published var tabItems: [NavigatorTab] = [] - - func setNavigatorTab(tab newTab: NavigatorTab) { - selectedTab = newTab - } + @Published var tabItems: [any WorkspacePanelContribution] = [] } diff --git a/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorContributions.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorContributions.swift new file mode 100644 index 0000000000..e3aaed8c4f --- /dev/null +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorContributions.swift @@ -0,0 +1,18 @@ +// +// NavigatorContributions.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import CodeEditUI +import SwiftUI + +/// The project navigator is shell chrome: it has no owning package and stays app-side permanently. +struct ProjectNavigatorContribution: WorkspacePanelContribution { + let id = PanelTabID.project + let title = "Project" + let systemImage = "folder" + var content: AnyView { AnyView(ProjectNavigatorView()) } + var bottomView: AnyView? { AnyView(ProjectNavigatorToolbarBottom()) } +} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/FilterDropDownIconButton.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/FilterDropDownIconButton.swift similarity index 100% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/FilterDropDownIconButton.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/FilterDropDownIconButton.swift diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/FileSystemTableViewCell.swift similarity index 84% rename from CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/FileSystemTableViewCell.swift index 7aa5ea3cf0..e827101d99 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/FileSystemTableViewCell.swift @@ -1,11 +1,14 @@ // -// FileSystemOutlineView.swift +// FileSystemTableViewCell.swift // CodeEdit // // Created by TAY KAI QUAN on 14/8/22. // import SwiftUI +import CodeEditSettings +import CEWorkspaceFileManager +import CodeEditCore class FileSystemTableViewCell: StandardTableViewCell { @@ -14,7 +17,7 @@ class FileSystemTableViewCell: StandardTableViewCell { var changeLabelLargeWidth: NSLayoutConstraint! var changeLabelSmallWidth: NSLayoutConstraint! - private let prefs = Settings.shared.preferences.general + let prefs: GeneralSettings private var navigatorFilter: String? /// Initializes the `OutlineTableViewCell` with an `icon` and `label` @@ -25,7 +28,16 @@ class FileSystemTableViewCell: StandardTableViewCell { /// - isEditable: Set to true if the user should be able to edit the file name. /// - navigatorFilter: An optional string use to filter the navigator area. /// (Used for bolding and changing primary/secondary color). - init(frame frameRect: NSRect, item: CEWorkspaceFile?, isEditable: Bool = true, navigatorFilter: String? = nil) { + /// - generalSettings: The general settings, by value. AppKit cells cannot read the + /// environment, so the controller that builds them hands the value down. + init( + frame frameRect: NSRect, + item: CEWorkspaceFile?, + isEditable: Bool = true, + navigatorFilter: String? = nil, + generalSettings: GeneralSettings + ) { + self.prefs = generalSettings super.init(frame: frameRect, isEditable: isEditable) self.navigatorFilter = navigatorFilter @@ -45,7 +57,7 @@ class FileSystemTableViewCell: StandardTableViewCell { imageView?.image = item.nsIcon imageView?.contentTintColor = color(for: item) - let fileName = item.labelFileName() + let fileName = item.labelFileName(prefs) let fontSize = textField?.font?.pointSize ?? 12 guard let filter = navigatorFilter?.trimmingCharacters(in: .whitespacesAndNewlines), !filter.isEmpty else { @@ -105,6 +117,7 @@ class FileSystemTableViewCell: StandardTableViewCell { /// *Not Implemented* override init(frame frameRect: NSRect) { + self.prefs = GeneralSettings() super.init(frame: frameRect) fatalError(""" init(frame: ) isn't implemented on `OutlineTableViewCell`. @@ -154,20 +167,22 @@ let errorRed = NSColor(red: 1, green: 0, blue: 0, alpha: 0.2) extension FileSystemTableViewCell: NSTextFieldDelegate { func controlTextDidChange(_ obj: Notification) { guard let fileItem else { return } - textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed + textField?.backgroundColor = + fileItem.validateFileName(for: textField?.stringValue ?? "", prefs: prefs) ? .none : errorRed } func controlTextDidEndEditing(_ obj: Notification) { guard let fileItem else { return } do { - textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed - if fileItem.validateFileName(for: textField?.stringValue ?? "") { + textField?.backgroundColor = + fileItem.validateFileName(for: textField?.stringValue ?? "", prefs: prefs) ? .none : errorRed + if fileItem.validateFileName(for: textField?.stringValue ?? "", prefs: prefs) { let newURL = fileItem.url .deletingLastPathComponent() .appending(path: textField?.stringValue ?? "") - try workspace?.workspaceFileManager?.move(file: fileItem, to: newURL) + try workspace?.workspaceFileManager.move(file: fileItem, to: newURL) } else { - textField?.stringValue = fileItem.labelFileName() + textField?.stringValue = fileItem.labelFileName(prefs) } } catch { let alert = NSAlert(error: error) diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift index 8fd37b7db1..6bfcb1dbfc 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift @@ -1,21 +1,24 @@ // -// OutlineMenu.swift +// ProjectNavigatorMenu.swift // CodeEdit // // Created by Lukas Pistrol on 07.04.22. // import SwiftUI +import CEWorkspaceFileManager +import CodeEditCore import UniformTypeIdentifiers /// A subclass of `NSMenu` implementing the contextual menu for the project navigator +@MainActor final class ProjectNavigatorMenu: NSMenu { /// The item to show the contextual menu for var item: CEWorkspaceFile? /// The workspace, for opening the item - var workspace: WorkspaceDocument? + var workspace: Workspace? /// The `ProjectNavigatorViewController` is being called from. /// By sending it, we can access it's variables and functions. @@ -46,6 +49,7 @@ final class ProjectNavigatorMenu: NSMenu { /// Configures the menu based on the current selection in the outline view. /// - Menu items get added depending on the amount of selected items. + @MainActor private func setupMenu() { // swiftlint:disable:this function_body_length guard let item else { return } let showInFinder = menuItem("Show in Finder", action: #selector(showInFinder)) @@ -72,12 +76,12 @@ final class ProjectNavigatorMenu: NSMenu { let rename = menuItem("Rename", action: #selector(renameFile)) let trash = menuItem("Move to Trash", action: - item.url != workspace?.workspaceFileManager?.folderUrl + item.url != workspace?.workspaceFileManager.folderUrl ? #selector(trash) : nil) // trash has to be the previous menu item for delete.isAlternate to work correctly let delete = menuItem("Delete Immediately...", action: - item.url != workspace?.workspaceFileManager?.folderUrl + item.url != workspace?.workspaceFileManager.folderUrl ? #selector(delete) : nil) delete.keyEquivalentModifierMask = .option delete.isAlternate = true diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift similarity index 77% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 1aa65af926..7ad109333b 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -6,6 +6,8 @@ // import AppKit +import CEWorkspaceFileManager +import CodeEditCore import SwiftUI extension ProjectNavigatorMenu { @@ -67,7 +69,7 @@ extension ProjectNavigatorMenu { /// Open the items in order. sortedItems.forEach { item in - workspace?.editorManager?.openTab(item: item) + sender.workspaceNavigator.open(file: item, asTemporary: false) } } @@ -87,9 +89,9 @@ extension ProjectNavigatorMenu { func newFile() { guard let item else { return } do { - if let newFile = try workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) { - workspace?.listenerModel.highlightedFileItem = newFile - workspace?.editorManager?.openTab(item: newFile) + if let newFile = try workspace?.workspaceFileManager.addFile(fileName: "untitled", toFile: item) { + workspace?.revealRequests.send(newFile) + sender.workspaceNavigator.open(file: newFile, asTemporary: false) } } catch { let alert = NSAlert(error: error) @@ -98,12 +100,10 @@ extension ProjectNavigatorMenu { } } - /// Opens the rename file dialogue on the cell this was presented from. - @objc - func renameFile() { - guard let newFile = workspace?.listenerModel.highlightedFileItem else { return } - let row = sender.outlineView.row(forItem: newFile) - guard row > 0, + /// Puts the cell for `file` into edit mode, if that row is visible. + private func beginRenaming(_ file: CEWorkspaceFile) { + let row = sender.outlineView.row(forItem: file) + guard row >= 0, let cell = sender.outlineView.view( atColumn: 0, row: row, @@ -114,6 +114,13 @@ extension ProjectNavigatorMenu { sender.outlineView.window?.makeFirstResponder(cell.textField) } + /// Opens the rename dialogue on the cell this menu was presented from. + @objc + func renameFile() { + guard let item else { return } + beginRenaming(item) + } + // TODO: Automatically identified the file type /// Action that creates a new file with clipboard content @objc @@ -122,15 +129,15 @@ extension ProjectNavigatorMenu { do { let clipBoardContent = NSPasteboard.general.string(forType: .string)?.data(using: .utf8) if let clipBoardContent, !clipBoardContent.isEmpty, let newFile = try workspace? - .workspaceFileManager? + .workspaceFileManager .addFile( fileName: "untitled", toFile: item, contents: clipBoardContent ) { - workspace?.listenerModel.highlightedFileItem = newFile - workspace?.editorManager?.openTab(item: newFile) - renameFile() + workspace?.revealRequests.send(newFile) + sender.workspaceNavigator.open(file: newFile, asTemporary: false) + beginRenaming(newFile) } } catch { let alert = NSAlert(error: error) @@ -145,8 +152,8 @@ extension ProjectNavigatorMenu { func newFolder() { guard let item else { return } do { - if let newFolder = try workspace?.workspaceFileManager?.addFolder(folderName: "untitled", toFile: item) { - workspace?.listenerModel.highlightedFileItem = newFolder + if let newFolder = try workspace?.workspaceFileManager.addFolder(folderName: "untitled", toFile: item) { + workspace?.revealRequests.send(newFolder) } } catch { let alert = NSAlert(error: error) @@ -158,7 +165,7 @@ extension ProjectNavigatorMenu { /// Creates a new folder with the items selected. @objc func newFolderFromSelection() { - guard let workspace, let workspaceFileManager = workspace.workspaceFileManager else { return } + guard let workspaceFileManager = workspace?.workspaceFileManager else { return } let selectedItems = selectedItems() guard let parent = selectedItems.first?.parent else { return } @@ -190,13 +197,13 @@ extension ProjectNavigatorMenu { do { try selectedItems().forEach { item in withAnimation { - sender.editor?.closeTab(file: item) + sender.workspaceNavigator.closeTab(file: item) } guard FileManager.default.fileExists(atPath: item.url.path) else { // Was likely already trashed (eg selecting files in a folder and deleting the folder and files) return } - try workspace?.workspaceFileManager?.trash(file: item) + try workspace?.workspaceFileManager.trash(file: item) } reloadData() } catch { @@ -209,19 +216,37 @@ extension ProjectNavigatorMenu { /// Action that deletes the item immediately. @objc func delete() { + let selectedItems = selectedItems() + + let confirmation = NSAlert() + confirmation.alertStyle = .critical + confirmation.addButton(withTitle: "Delete") + confirmation.buttons.last?.hasDestructiveAction = true + confirmation.addButton(withTitle: "Cancel") + if selectedItems.count == 1, let only = selectedItems.first { + confirmation.messageText = "Do you want to delete \u{201C}\(only.name)\u{201D}?" + confirmation.informativeText = "This item will be deleted immediately. You can't undo this action." + } else { + confirmation.messageText = + "Are you sure you want to delete the \(selectedItems.count) selected items?" + // swiftlint:disable:next line_length + confirmation.informativeText = "\(selectedItems.count) items will be deleted immediately. You cannot undo this action." + } + + guard confirmation.runModal() == .alertFirstButtonReturn else { return } + do { - let selectedItems = selectedItems() if selectedItems.count == 1 { try selectedItems.forEach { item in - try workspace?.workspaceFileManager?.delete(file: item) + try workspace?.workspaceFileManager.delete(file: item) } } else { - try workspace?.workspaceFileManager?.batchDelete(files: selectedItems) + try workspace?.workspaceFileManager.batchDelete(files: selectedItems) } withAnimation { selectedItems.forEach { item in - sender.editor?.closeTab(file: item) + sender.workspaceNavigator.closeTab(file: item) } } @@ -238,7 +263,7 @@ extension ProjectNavigatorMenu { func duplicate() { do { try selectedItems().forEach { item in - try workspace?.workspaceFileManager?.duplicate(file: item) + try workspace?.workspaceFileManager.duplicate(file: item) } reloadData() } catch { @@ -261,7 +286,7 @@ extension ProjectNavigatorMenu { /// Copies the relative path of the selected files @objc func copyRelativePath() { - guard let rootPath = workspace?.workspaceFileManager?.folderUrl else { + guard let rootPath = workspace?.workspaceFileManager.folderUrl else { return } let paths = selectedItems().map { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorNSOutlineView.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorNSOutlineView.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorNSOutlineView.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorNSOutlineView.swift index 58e77ebfca..23a0f03cea 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorNSOutlineView.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorNSOutlineView.swift @@ -6,6 +6,7 @@ // import AppKit +import CodeEditCore final class ProjectNavigatorNSOutlineView: NSOutlineView, NSMenuItemValidation { override func performKeyEquivalent(with event: NSEvent) -> Bool { diff --git a/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift new file mode 100644 index 0000000000..785f12db29 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -0,0 +1,148 @@ +// +// ProjectNavigatorOutlineView.swift +// CodeEdit +// +// Created by Lukas Pistrol on 05.04.22. +// + +import SwiftUI +import CEWorkspaceFileManager +import CodeEditCore +import CodeEditSettings +import CEEditor +import Combine + +/// Wraps an ``OutlineViewController`` inside a `NSViewControllerRepresentable` +struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { + + @Environment(\.workspace) + private var workspace + @EnvironmentObject var editorManager: EditorManager + + @Environment(\.activeEditorState) + private var activeEditorState + @Environment(\.workspaceNavigator) + private var workspaceNavigator + + @AppSettings(\.general) + private var generalSettings + + @EnvironmentObject private var settingsStore: PersistentSettingsStore + + typealias NSViewControllerType = ProjectNavigatorViewController + + func makeNSViewController(context: Context) -> ProjectNavigatorViewController { + let controller = ProjectNavigatorViewController() + controller.generalSettings = generalSettings + controller.settingsAccessor = settingsStore + controller.activeEditorState = activeEditorState + controller.workspaceNavigator = workspaceNavigator + + context.coordinator.controller = controller + context.coordinator.observeActiveFile(activeEditorState) + + guard let workspace else { + assertionFailure("ProjectNavigatorOutlineView built with no workspace in the environment") + return controller + } + controller.workspace = workspace + workspace.workspaceFileManager.addObserver(context.coordinator) + + return controller + } + + func updateNSViewController(_ nsViewController: ProjectNavigatorViewController, context: Context) { + nsViewController.settingsAccessor = settingsStore + nsViewController.generalSettings = generalSettings + nsViewController.rowHeight = generalSettings.projectNavigatorSize.rowHeight + /// if the window becomes active from background, it will restore the selection to outline view. + nsViewController.updateSelection(itemID: activeEditorState.selectedFile?.id) + return + } + + func makeCoordinator() -> Coordinator { + Coordinator(workspace) + } + + @MainActor + class Coordinator: NSObject, WorkspaceFileObserver { + init(_ workspace: Workspace?) { + self.workspace = workspace + self.fileManager = workspace?.workspaceFileManager + super.init() + + guard let workspace else { return } + + workspace.revealRequests + .sink(receiveValue: { [weak self] file in + self?.controller?.reveal(file) + }) + .store(in: &cancellables) + do { + let projectNavigatorViewModel = workspace.projectNavigatorViewModel + projectNavigatorViewModel.$navigatorFilter + .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) + .sink { [weak self] _ in + self?.controller?.handleFilterChange() + } + .store(in: &cancellables) + Publishers.Merge( + projectNavigatorViewModel.$sourceControlFilter, + projectNavigatorViewModel.$sortFoldersOnTop + ) + .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) + .sink { [weak self] _ in + self?.controller?.handleFilterChange() + } + .store(in: &cancellables) + } + } + + var cancellables: Set = [] + private var selectionCancellable: AnyCancellable? + weak var workspace: Workspace? + weak var fileManager: CEWorkspaceFileManager? + weak var controller: ProjectNavigatorViewController? + + /// Subscribe to the active-file read-model so the outline highlights the active file. + /// Wired from `makeNSViewController`, where the `@Environment` value is reliably populated. + func observeActiveFile(_ state: ActiveEditorState) { + // React to *changes* only. `selectedFilePublisher` is a `CurrentValueSubject` that + // replays the current value on subscribe; skip it so we don't call `updateSelection` + // (which touches the IUO `outlineView`) during `makeNSViewController`, before the view + // has loaded. The initial selection is set by `updateNSViewController`. + selectionCancellable = state.selectedFilePublisher + .dropFirst() + .sink { [weak self] file in + self?.controller?.updateSelection(itemID: file?.id) + } + } + + func fileManagerUpdated(updatedItems: Set) { + guard let outlineView = controller?.outlineView else { return } + let selectedRows = outlineView.selectedRowIndexes.compactMap({ outlineView.item(atRow: $0) }) + + // If some text view inside the outline view is first responder right now, push the update off + // until editing is finished using the `shouldReloadAfterDoneEditing` flag. + if outlineView.window?.firstResponder !== outlineView + && outlineView.window?.firstResponder is NSTextView + && (outlineView.window?.firstResponder as? NSView)?.isDescendant(of: outlineView) == true { + controller?.shouldReloadAfterDoneEditing = true + } else { + for item in updatedItems { + outlineView.reloadItem(item, reloadChildren: true) + } + } + + // Restore selected items where the files still exist. + let selectedIndexes = selectedRows.compactMap({ outlineView.row(forItem: $0) }).filter({ $0 >= 0 }) + controller?.shouldSendSelectionUpdate = false + outlineView.selectRowIndexes(IndexSet(selectedIndexes), byExtendingSelection: false) + controller?.shouldSendSelectionUpdate = true + } + + deinit { + fileManager?.removeObserver(self) + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift similarity index 79% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift index 82db7b1649..2d169a5a9d 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -1,11 +1,13 @@ // -// OutlineTableViewCell.swift +// ProjectNavigatorTableViewCell.swift // CodeEdit // // Created by Lukas Pistrol on 07.04.22. // import SwiftUI +import CodeEditSettings +import CodeEditCore protocol OutlineTableViewCellDelegate: AnyObject { func moveFile(file: CEWorkspaceFile, to destination: URL) @@ -30,9 +32,16 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { item: CEWorkspaceFile?, isEditable: Bool = true, delegate: OutlineTableViewCellDelegate? = nil, - navigatorFilter: String? = nil + navigatorFilter: String? = nil, + generalSettings: GeneralSettings ) { - super.init(frame: frameRect, item: item, isEditable: isEditable, navigatorFilter: navigatorFilter) + super.init( + frame: frameRect, + item: item, + isEditable: isEditable, + navigatorFilter: navigatorFilter, + generalSettings: generalSettings + ) self.textField?.setAccessibilityIdentifier("ProjectNavigatorTableViewCell-\(item?.name ?? "")") self.delegate = delegate } @@ -56,14 +65,15 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { override func controlTextDidEndEditing(_ obj: Notification) { guard let fileItem else { return } - textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed - if fileItem.validateFileName(for: textField?.stringValue ?? "") { + textField?.backgroundColor = + fileItem.validateFileName(for: textField?.stringValue ?? "", prefs: prefs) ? .none : errorRed + if fileItem.validateFileName(for: textField?.stringValue ?? "", prefs: prefs) { let destinationURL = fileItem.url .deletingLastPathComponent() .appending(path: textField?.stringValue ?? "") delegate?.moveFile(file: fileItem, to: destinationURL) } else { - textField?.stringValue = fileItem.labelFileName() + textField?.stringValue = fileItem.labelFileName(prefs) } delegate?.cellDidFinishEditing() } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSMenuDelegate.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSMenuDelegate.swift similarity index 97% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSMenuDelegate.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSMenuDelegate.swift index 0b080127bf..0561f6b73f 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSMenuDelegate.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSMenuDelegate.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore // MARK: - NSMenuDelegate extension ProjectNavigatorViewController: NSMenuDelegate { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift similarity index 65% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift index 2cc45c4b19..45f42ba826 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift @@ -6,6 +6,8 @@ // import AppKit +import CEWorkspaceFileManager +import CodeEditCore extension ProjectNavigatorViewController: NSOutlineViewDataSource { /// Retrieves the children of a given item for the outline view, applying the current filter if necessary. @@ -13,17 +15,22 @@ extension ProjectNavigatorViewController: NSOutlineViewDataSource { if let cachedChildren = filteredContentChildren[item] { return cachedChildren .sorted { lhs, rhs in - workspace?.sortFoldersOnTop == true ? lhs.isFolder && !rhs.isFolder : lhs.name < rhs.name + workspace?.projectNavigatorViewModel.sortFoldersOnTop == true + ? lhs.isFolder && !rhs.isFolder : lhs.name < rhs.name } } - if let workspace, let children = workspace.workspaceFileManager?.childrenOfFile(item) { - if !workspace.navigatorFilter.isEmpty || workspace.sourceControlFilter { + if let workspace, let children = workspace.workspaceFileManager.childrenOfFile(item) { + let navigatorFilter = workspace.projectNavigatorViewModel.navigatorFilter ?? "" + let sourceControlFilter = workspace.projectNavigatorViewModel.sourceControlFilter ?? false + let sortFoldersOnTop = workspace.projectNavigatorViewModel.sortFoldersOnTop ?? true + + if !navigatorFilter.isEmpty || sourceControlFilter { let filteredChildren = children.filter { fileSearchMatches( - workspace.navigatorFilter, + navigatorFilter, for: $0, - sourceControlFilter: workspace.sourceControlFilter + sourceControlFilter: sourceControlFilter ) } @@ -33,7 +40,7 @@ extension ProjectNavigatorViewController: NSOutlineViewDataSource { return children .sorted { lhs, rhs in - workspace.sortFoldersOnTop ? lhs.isFolder && !rhs.isFolder : lhs.name < rhs.name + sortFoldersOnTop ? lhs.isFolder && !rhs.isFolder : lhs.name < rhs.name } } @@ -95,45 +102,34 @@ extension ProjectNavigatorViewController: NSOutlineViewDataSource { guard let pasteboardItems = info.draggingPasteboard.readObjects(forClasses: [NSURL.self]) else { return false } let fileItemURLS = pasteboardItems.compactMap { $0 as? URL } - guard let fileItemDestination = item as? CEWorkspaceFile else { return false } - let destParentURL = fileItemDestination.url - - for fileItemURL in fileItemURLS { - let destURL = destParentURL.appending(path: fileItemURL.lastPathComponent) - // cancel dropping file item on self or in parent directory - if fileItemURL == destURL || fileItemURL == destParentURL { - return false - } - - // Needs to come before call to .removeItem or else race condition occurs - var srcFileItem: CEWorkspaceFile? = workspace?.workspaceFileManager?.getFile(fileItemURL.path) - // If srcFileItem is nil, fileItemUrl is an external file url. - if srcFileItem == nil { - srcFileItem = CEWorkspaceFile(url: URL(fileURLWithPath: fileItemURL.path)) - } + guard let fileItemDestination = item as? CEWorkspaceFile, + let workspace else { return false } - guard let srcFileItem else { - return false - } + let dropHandler = FileDropHandler() + let isCopy = info.draggingSourceOperationMask == .copy - if CEWorkspaceFile.fileManager.fileExists(atPath: destURL.path) { - let shouldReplace = replaceFileDialog(fileName: fileItemURL.lastPathComponent) - guard shouldReplace else { - return false + do { + let operations = try dropHandler.execute( + urls: fileItemURLS, + destinationParent: fileItemDestination, + isCopyOperation: isCopy, + in: workspace, + confirmReplace: { [weak self] fileName in + self?.replaceFileDialog(fileName: fileName) ?? false } - do { - try CEWorkspaceFile.fileManager.removeItem(at: destURL) - } catch { - fatalError(error.localizedDescription) + ) + + for operation in operations { + if operation.isCopy { + self.copyFile(file: operation.source, to: operation.destination) + } else { + self.moveFile(file: operation.source, to: operation.destination) } } - if info.draggingSourceOperationMask == .copy { - self.copyFile(file: srcFileItem, to: destURL) - } else { - self.moveFile(file: srcFileItem, to: destURL) - } + return !operations.isEmpty + } catch { + fatalError(error.localizedDescription) } - return true } func replaceFileDialog(fileName: String) -> Bool { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift similarity index 85% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 9256c3e3e1..853a46e24c 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -6,6 +6,9 @@ // import AppKit +import CodeEditSettings +import CEWorkspaceFileManager +import CodeEditCore extension ProjectNavigatorViewController: NSOutlineViewDelegate { func outlineView( @@ -28,7 +31,8 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { frame: frameRect, item: item as? CEWorkspaceFile, delegate: self, - navigatorFilter: workspace?.navigatorFilter + navigatorFilter: workspace?.projectNavigatorViewModel.navigatorFilter, + generalSettings: generalSettings ) return cell } @@ -46,8 +50,8 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { if !item.isFolder && shouldSendSelectionUpdate { shouldSendSelectionUpdate = false - if workspace?.editorManager?.activeEditor.selectedTab?.file != item { - workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) + if activeEditorState?.selectedFile != item { + workspaceNavigator.open(file: item, asTemporary: true) } shouldSendSelectionUpdate = true } @@ -60,12 +64,13 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { func outlineViewItemDidExpand(_ notification: Notification) { /// Save expanded items' state to restore when finish filtering. guard let workspace else { return } - if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile { + if workspace.projectNavigatorViewModel.navigatorFilter.isEmpty ?? true, + let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile { expandedItems.insert(item) } - guard let id = workspace.editorManager?.activeEditor.selectedTab?.file.id, - let item = workspace.workspaceFileManager?.getFile(id, createIfNotFound: true), + guard let id = activeEditorState?.selectedFile?.id, + let item = workspace.workspaceFileManager.getFile(id, createIfNotFound: true), /// update outline selection only if the parent of selected item match with expanded item item.parent === notification.userInfo?["NSObject"] as? CEWorkspaceFile else { return @@ -79,14 +84,15 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { func outlineViewItemDidCollapse(_ notification: Notification) { /// Save expanded items' state to restore when finish filtering. guard let workspace else { return } - if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile { + if workspace.projectNavigatorViewModel.navigatorFilter.isEmpty ?? true, + let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile { expandedItems.remove(item) } } func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? { guard let id = object as? CEWorkspaceFile.ID, - let item = workspace?.workspaceFileManager?.getFile(id, createIfNotFound: true) else { return nil } + let item = workspace?.workspaceFileManager.getFile(id, createIfNotFound: true) else { return nil } return item } @@ -102,12 +108,12 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { /// - forcesReveal: The boolean to indicates whether or not it should force to reveal the selected file. func select(by id: EditorTabID, forcesReveal: Bool) { guard case .codeEditor(let path) = id, - let item = workspace?.workspaceFileManager?.getFile(path, createIfNotFound: true) else { + let item = workspace?.workspaceFileManager.getFile(path, createIfNotFound: true) else { return } // If the user has set "Reveal file on selection change" to on or it is forced to reveal, // we need to reveal the item before selecting the row. - if Settings.shared.preferences.general.revealFileOnFocusChange || forcesReveal { + if settings.value(GeneralSettings.self).revealFileOnFocusChange || forcesReveal { reveal(item) } let row = outlineView.row(forItem: item) diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift similarity index 60% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 91d68b42e2..3555335a62 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -1,28 +1,24 @@ // -// OutlintViewController+OutlineTableViewCellDelegate.swift +// ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift // CodeEdit // // Created by Ziyuan Zhao on 2023/2/5. // import Foundation +import CEWorkspaceFileManager +import CodeEditCore import AppKit // MARK: - OutlineTableViewCellDelegate extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { func moveFile(file: CEWorkspaceFile, to destination: URL) { + guard let workspace else { return } do { - guard let newFile = try workspace?.workspaceFileManager?.move(file: file, to: destination), - !newFile.isFolder else { - return - } + let fileMover = FileMover() + _ = try fileMover.execute(file: file, to: destination, in: workspace) outlineView.reloadItem(file.parent, reloadChildren: true) - if !file.isFolder { - workspace?.editorManager?.editorLayout.closeAllTabs(of: file) - } - workspace?.listenerModel.highlightedFileItem = newFile - workspace?.editorManager?.openTab(item: newFile) } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") @@ -32,7 +28,7 @@ extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { func copyFile(file: CEWorkspaceFile, to destination: URL) { do { - try workspace?.workspaceFileManager?.copy(file: file, to: destination) + try workspace?.workspaceFileManager.copy(file: file, to: destination) } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift similarity index 74% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift index 1cdaabe686..9c295ae80f 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -1,13 +1,16 @@ // -// OutlineViewController.swift +// ProjectNavigatorViewController.swift // CodeEdit // // Created by Lukas Pistrol on 07.04.22. // import AppKit +import CodeEditSettings +import CEWorkspaceFileManager import SwiftUI import OSLog +import CodeEditCore /// A `NSViewController` that handles the **ProjectNavigatorView** in the **NavigatorArea**. /// @@ -27,28 +30,59 @@ final class ProjectNavigatorViewController: NSViewController { /// /// Also creates a top level item "root" which represents the projects root directory and automatically expands it. var content: [CEWorkspaceFile] { - guard let folderURL = workspace?.workspaceFileManager?.folderUrl else { return [] } - guard let root = workspace?.workspaceFileManager?.getFile(folderURL.path) else { return [] } + guard let folderURL = workspace?.workspaceFileManager.folderUrl else { return [] } + guard let root = workspace?.workspaceFileManager.getFile(folderURL.path) else { return [] } return [root] } var filteredContentChildren: [CEWorkspaceFile: [CEWorkspaceFile]] = [:] var expandedItems: Set = [] - weak var workspace: WorkspaceDocument? - weak var editor: Editor? + weak var workspace: Workspace? - var iconColor: SettingsData.FileIconStyle = .color { + /// The navigation command interface; assigned by `ProjectNavigatorOutlineView` from the + /// environment. No-op until set so the controller stays constructible in isolation. + var workspaceNavigator: WorkspaceNavigator = NoOpWorkspaceNavigator() + weak var activeEditorState: (any ActiveEditorState)? + + /// The settings store, pushed in from `ProjectNavigatorOutlineView`. AppKit controllers cannot + /// read the SwiftUI environment, so the representable that owns this one hands it down. + /// + /// Optional with no default rather than a defaulting reader: a controller reached before its + /// representable has pushed a store is a wiring bug, and a stand-in value would answer with + /// plausible defaults and hide it. Read through ``settings``. + var settingsAccessor: SettingsAccessing? + + /// The pushed-in store, or a loud failure in debug. + var settings: SettingsAccessing { + guard let settingsAccessor else { + assertionFailure("ProjectNavigatorViewController used before a settings store was pushed in") + return DefaultSettingsReader() + } + return settingsAccessor + } + + /// The general settings, by value — the source for cell construction and the four fields that + /// require a reload when they change. + /// + /// `fileIconStyle` colours the icons; `fileExtensionsVisibility`, `shownFileExtensions` and + /// `hiddenFileExtensions` drive `CEWorkspaceFile.labelFileName(_:)`, which is read when a cell is + /// built. Cells are only built by `outlineView(_:viewFor:)`, so without a reload a preference + /// change leaves every visible label showing the text it was born with — the same reason + /// `rowHeight` reloads below. + var generalSettings: GeneralSettings = .init() { willSet { - if newValue != iconColor { + if newValue.fileIconStyle != generalSettings.fileIconStyle + || newValue.fileExtensionsVisibility != generalSettings.fileExtensionsVisibility + || newValue.shownFileExtensions != generalSettings.shownFileExtensions + || newValue.hiddenFileExtensions != generalSettings.hiddenFileExtensions { outlineView?.reloadData() } } } - var fileExtensionsVisibility: SettingsData.FileExtensionsVisibility = .showAll - var shownFileExtensions: SettingsData.FileExtensions = .default - var hiddenFileExtensions: SettingsData.FileExtensions = .default + /// The icon-colouring preference, kept as a name of its own because it reads as one. + var iconColor: GeneralSettings.FileIconStyle { generalSettings.fileIconStyle } var rowHeight: Double = 22 { willSet { @@ -67,7 +101,8 @@ final class ProjectNavigatorViewController: NSViewController { var shouldReloadAfterDoneEditing: Bool = false var filterIsEmpty: Bool { - workspace?.navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true + workspace?.projectNavigatorViewModel.navigatorFilter + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true } /// Setup the ``scrollView`` and ``outlineView`` @@ -124,7 +159,7 @@ final class ProjectNavigatorViewController: NSViewController { self.outlineView.dataSource = self self.outlineView.delegate = self self.outlineView.autosaveExpandedItems = true - self.outlineView.autosaveName = workspace?.workspaceFileManager?.folderUrl.path ?? "" + self.outlineView.autosaveName = workspace?.workspaceFileManager.folderUrl.path ?? "" self.outlineView.headerView = nil self.outlineView.menu = ProjectNavigatorMenu(self) self.outlineView.menu?.delegate = self @@ -159,7 +194,7 @@ final class ProjectNavigatorViewController: NSViewController { /// Forces to reveal the selected file through the command regardless of the auto reveal setting @objc func revealFile(_ sender: Any) { - updateSelection(itemID: workspace?.editorManager?.activeEditor.selectedTab?.file.id, forcesReveal: true) + updateSelection(itemID: activeEditorState?.selectedFile?.id, forcesReveal: true) } /// Updates the selection of the ``outlineView`` whenever it changes. @@ -189,8 +224,8 @@ final class ProjectNavigatorViewController: NSViewController { } else { outlineView.expandItem(item) } - } else if Settings[\.navigation].navigationStyle == .openInTabs { - workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: false) + } else if settings.value(NavigationSettings.self).navigationStyle == .openInTabs { + workspaceNavigator.open(file: item, asTemporary: false) } } @@ -212,7 +247,7 @@ final class ProjectNavigatorViewController: NSViewController { guard let workspace else { return } /// If the filter is empty, show all items and restore the expanded state. - if workspace.sourceControlFilter || !filterIsEmpty { + if workspace.projectNavigatorViewModel.sourceControlFilter == true || !filterIsEmpty { outlineView.autosaveExpandedItems = false /// Expand all items for search. outlineView.expandItem(outlineView.item(atRow: 0), expandChildren: true) @@ -248,7 +283,7 @@ final class ProjectNavigatorViewController: NSViewController { return true } - if let children = workspace?.workspaceFileManager?.childrenOfFile(item) { + if let children = workspace?.workspaceFileManager.childrenOfFile(item) { return children.contains { fileSearchMatches(filter, for: $0, sourceControlFilter: sourceControlFilter) } } @@ -261,7 +296,7 @@ final class ProjectNavigatorViewController: NSViewController { private func saveAllContentChildren(for item: CEWorkspaceFile) { guard item.isFolder, filteredContentChildren[item] == nil else { return } - if let children = workspace?.workspaceFileManager?.childrenOfFile(item) { + if let children = workspace?.workspaceFileManager.childrenOfFile(item) { filteredContentChildren[item] = children for child in children.filter({ $0.isFolder }) { saveAllContentChildren(for: child) diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/StandardTableViewCell.swift similarity index 99% rename from CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/StandardTableViewCell.swift index 3f1becd8d4..bae2778516 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/OutlineView/StandardTableViewCell.swift @@ -10,7 +10,7 @@ import SwiftUI class StandardTableViewCell: NSTableCellView { weak var secondaryLabel: NSTextField? - weak var workspace: WorkspaceDocument? + weak var workspace: Workspace? var secondaryLabelRightAligned: Bool = true { didSet { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift similarity index 68% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index 6d465d1360..69ae7544f0 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CEWorkspaceFileManager +import CodeEditUI +import CodeEditCore struct ProjectNavigatorToolbarBottom: View { @Environment(\.controlActiveState) @@ -14,15 +17,25 @@ struct ProjectNavigatorToolbarBottom: View { @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var workspace: WorkspaceDocument - @EnvironmentObject var editorManager: EditorManager + @Environment(\.activeEditorState) + private var activeEditorState + @Environment(\.workspaceNavigator) + private var workspaceNavigator + @EnvironmentObject var projectNavigatorViewModel: ProjectNavigatorViewModel + + @Environment(\.workspaceFileManager) + private var workspaceFileManager @State var recentsFilter: Bool = false var body: some View { NavigatorFilterView( - text: $workspace.navigatorFilter, - hasValue: { !workspace.navigatorFilter.isEmpty || recentsFilter || workspace.sourceControlFilter }, + text: $projectNavigatorViewModel.navigatorFilter, + hasValue: { + !projectNavigatorViewModel.navigatorFilter.isEmpty + || recentsFilter + || projectNavigatorViewModel.sourceControlFilter + }, menu: { addNewFileButton }, leadingAccessories: { leadingAccessories }, trailingAccessories: { trailingAccessories } @@ -33,18 +46,18 @@ struct ProjectNavigatorToolbarBottom: View { FilterDropDownIconButton(menu: { ForEach([(true, "Folders on top"), (false, "Alphabetically")], id: \.0) { value, title in Toggle(title, isOn: Binding(get: { - workspace.sortFoldersOnTop == value + projectNavigatorViewModel.sortFoldersOnTop == value }, set: { _ in // Avoid calling the handleFilterChange method - if workspace.sortFoldersOnTop != value { - workspace.sortFoldersOnTop = value + if projectNavigatorViewModel.sortFoldersOnTop != value { + projectNavigatorViewModel.sortFoldersOnTop = value } })) } - }, isOn: !workspace.navigatorFilter.isEmpty) + }, isOn: !projectNavigatorViewModel.navigatorFilter.isEmpty) .padding(.leading, 4) .foregroundStyle( - workspace.navigatorFilter.isEmpty + projectNavigatorViewModel.navigatorFilter.isEmpty ? Color(nsColor: .secondaryLabelColor) : Color(nsColor: .controlAccentColor) ) @@ -57,7 +70,7 @@ struct ProjectNavigatorToolbarBottom: View { Image(systemName: "clock") } .help("Show only recent files") - Toggle(isOn: $workspace.sourceControlFilter) { + Toggle(isOn: $projectNavigatorViewModel.sourceControlFilter) { Image(systemName: "plusminus.circle") } .help("Show only files with source-control status") @@ -69,14 +82,14 @@ struct ProjectNavigatorToolbarBottom: View { /// Retrieves the active tab URL from the underlying editor instance, if theres no /// active tab, fallbacks to the workspace's root directory private func activeTabURL() -> URL { - if let selectedTab = editorManager.activeEditor.selectedTab { - if selectedTab.file.isFolder { - return selectedTab.file.url + if let file = activeEditorState.selectedFile { + if file.isFolder { + return file.url } // If the current active tab belongs to a file, pop the filename from // the path URL to retrieve the folder URL - let activeTabFileURL = selectedTab.file.url + let activeTabFileURL = file.url if URLComponents(url: activeTabFileURL, resolvingAgainstBaseURL: false) != nil { var pathComponents = activeTabFileURL.pathComponents @@ -87,21 +100,21 @@ struct ProjectNavigatorToolbarBottom: View { } } - return workspace.workspaceFileManager.unsafelyUnwrapped.folderUrl + return workspaceFileManager.unsafelyUnwrapped.folderUrl } @ViewBuilder private var addNewFileButton: some View { Menu { Button("Add File") { let filePathURL = activeTabURL() - guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } + guard let rootFile = workspaceFileManager?.getFile(filePathURL.path) else { return } do { - if let newFile = try workspace.workspaceFileManager?.addFile( + if let newFile = try workspaceFileManager?.addFile( fileName: "untitled", toFile: rootFile ) { - workspace.listenerModel.highlightedFileItem = newFile - workspace.editorManager?.openTab(item: newFile) + workspaceNavigator.reveal(file: newFile) + workspaceNavigator.open(file: newFile, asTemporary: false) } } catch { let alert = NSAlert(error: error) @@ -112,13 +125,13 @@ struct ProjectNavigatorToolbarBottom: View { Button("Add Folder") { let filePathURL = activeTabURL() - guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } + guard let rootFile = workspaceFileManager?.getFile(filePathURL.path) else { return } do { - if let newFolder = try workspace.workspaceFileManager?.addFolder( + if let newFolder = try workspaceFileManager?.addFolder( folderName: "untitled", toFile: rootFile ) { - workspace.listenerModel.highlightedFileItem = newFolder + workspaceNavigator.reveal(file: newFolder) } } catch { let alert = NSAlert(error: error) @@ -143,7 +156,7 @@ struct ProjectNavigatorToolbarBottom: View { /// when the user clears the filter. private var clearFilterButton: some View { Button { - workspace.navigatorFilter = "" + projectNavigatorViewModel.navigatorFilter = "" NSApp.keyWindow?.makeFirstResponder(nil) } label: { Image(systemName: "xmark.circle.fill") diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorView.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorView.swift similarity index 100% rename from CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorView.swift rename to CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorView.swift diff --git a/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorViewModel.swift b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorViewModel.swift new file mode 100644 index 0000000000..4b19ad6d2d --- /dev/null +++ b/CodeEdit/WorkspaceWindow/NavigatorArea/ProjectNavigator/ProjectNavigatorViewModel.swift @@ -0,0 +1,19 @@ +// +// ProjectNavigatorViewModel.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 03/07/2026. +// + +import Foundation + +/// UI state for the Project Navigator: its filter text and sort/filter toggles. +/// Extracted from `Workspace` so navigator views don't depend on the whole workspace hub. +@MainActor +final class ProjectNavigatorViewModel: ObservableObject { + @Published var navigatorFilter: String = "" + @Published var sortFoldersOnTop: Bool = true + @Published var sourceControlFilter: Bool = false + + init() {} +} diff --git a/CodeEdit/WorkspaceWindow/NotificationPanelViewModel+Toolbar.swift b/CodeEdit/WorkspaceWindow/NotificationPanelViewModel+Toolbar.swift new file mode 100644 index 0000000000..03f00975b7 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/NotificationPanelViewModel+Toolbar.swift @@ -0,0 +1,42 @@ +// +// NotificationPanelViewModel+Toolbar.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import AppKit +import CENotifications + +/// App-shell integration for the notification toolbar badge. +/// +/// Window-toolbar mutation is app-shell responsibility (it uses the app-defined +/// `.notificationItem` / `.activityViewer` identifiers), so this lives in the app +/// target rather than the `Notifications` package. The package signals a refresh via +/// ``NotificationPanelViewModel/onToolbarUpdateRequested``, wired up in `WorkspaceWindowManager`. +extension NotificationPanelViewModel { + func updateToolbarItem() { + if #available(macOS 15.0, *) { + guard let windowController, let toolbar = windowController.window?.toolbar else { + return + } + + let shouldShow = !visibleNotifications.isEmpty + || notificationManager.unreadCount > 0 + if shouldShow && toolbar.items.filter({ $0.itemIdentifier == .notificationItem }).first == nil { + guard let activityItemIdx = toolbar.items + .firstIndex(where: { $0.itemIdentifier == .activityViewer }) else { + return + } + toolbar.insertItem(withItemIdentifier: .space, at: activityItemIdx + 1) + toolbar.insertItem(withItemIdentifier: .notificationItem, at: activityItemIdx + 2) + } + + if !shouldShow, let index = toolbar.items + .firstIndex(where: { $0.itemIdentifier == .notificationItem }) { + toolbar.removeItem(at: index) + toolbar.removeItem(at: index) + } + } + } +} diff --git a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyListItemView.swift b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyListItemView.swift similarity index 98% rename from CodeEdit/Features/OpenQuickly/Views/OpenQuicklyListItemView.swift rename to CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyListItemView.swift index 6acd23e381..61fc93b16e 100644 --- a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyListItemView.swift +++ b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyListItemView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct OpenQuicklyListItemView: View { private let baseDirectory: URL diff --git a/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyPreviewView.swift b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyPreviewView.swift new file mode 100644 index 0000000000..3a38ccbd45 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyPreviewView.swift @@ -0,0 +1,20 @@ +// +// OpenQuicklyPreviewView.swift +// CodeEdit +// +// Created by Pavel Kasila on 20.03.22. +// + +import SwiftUI +import CodeEditCore + +struct OpenQuicklyPreviewView: View { + let item: CEWorkspaceFile + + @Environment(\.filePreview) + private var filePreview + + var body: some View { + filePreview(item) + } +} diff --git a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyView.swift b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyView.swift similarity index 89% rename from CodeEdit/Features/OpenQuickly/Views/OpenQuicklyView.swift rename to CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyView.swift index 4525a50835..abee2083f5 100644 --- a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyView.swift +++ b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyView.swift @@ -6,9 +6,13 @@ // import SwiftUI +import CodeEditUI +import CEWorkspaceFileManager +import CodeEditCore struct OpenQuicklyView: View { - @EnvironmentObject private var workspace: WorkspaceDocument + @Environment(\.workspaceFileManager) + private var workspaceFileManager private let onClose: () -> Void private let openFile: (CEWorkspaceFile) -> Void @@ -42,7 +46,7 @@ struct OpenQuicklyView: View { } preview: { searchResult in OpenQuicklyPreviewView(item: CEWorkspaceFile(url: searchResult.fileURL)) } onRowClick: { searchResult in - guard let file = workspace.workspaceFileManager?.getFile( + guard let file = workspaceFileManager?.getFile( searchResult.fileURL.relativePath, createIfNotFound: true ) else { diff --git a/CodeEdit/Features/OpenQuickly/ViewModels/OpenQuicklyViewModel.swift b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyViewModel.swift similarity index 95% rename from CodeEdit/Features/OpenQuickly/ViewModels/OpenQuicklyViewModel.swift rename to CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyViewModel.swift index 9f54bb58e8..7b60382a73 100644 --- a/CodeEdit/Features/OpenQuickly/ViewModels/OpenQuicklyViewModel.swift +++ b/CodeEdit/WorkspaceWindow/OpenQuickly/OpenQuicklyViewModel.swift @@ -71,7 +71,7 @@ final class OpenQuicklyViewModel: ObservableObject { } } - let fuzzySearchResults = await filteredFiles.fuzzySearch( + let fuzzyMatchResults = await filteredFiles.fuzzyMatches( query: self.query.trimmingCharacters(in: .whitespaces) ).concurrentMap { SearchResult( @@ -82,7 +82,7 @@ final class OpenQuicklyViewModel: ObservableObject { guard !Task.isCancelled else { return } await MainActor.run { - self.searchResults = fuzzySearchResults + self.searchResults = fuzzyMatchResults print("Duration: \(Date().timeIntervalSince(startTime))") } } diff --git a/CodeEdit/Utils/Extensions/URL/URL+FuzzySearchable.swift b/CodeEdit/WorkspaceWindow/OpenQuickly/URL+FuzzyMatchable.swift similarity index 51% rename from CodeEdit/Utils/Extensions/URL/URL+FuzzySearchable.swift rename to CodeEdit/WorkspaceWindow/OpenQuickly/URL+FuzzyMatchable.swift index ef6c363565..5282cd0ba9 100644 --- a/CodeEdit/Utils/Extensions/URL/URL+FuzzySearchable.swift +++ b/CodeEdit/WorkspaceWindow/OpenQuickly/URL+FuzzyMatchable.swift @@ -1,14 +1,15 @@ // -// URL+FuzzySearchable.swift +// URL+FuzzyMatchable.swift // CodeEdit // // Created by Tommy Ludwig on 03.02.24. // import Foundation +import CodeEditCore -extension URL: FuzzySearchable { - var searchableString: String { +extension URL: FuzzyMatchable { + public var searchableString: String { return self.lastPathComponent } } diff --git a/CodeEdit/Utils/Extensions/URL/URL+Identifiable.swift b/CodeEdit/WorkspaceWindow/OpenQuickly/URL+Identifiable.swift similarity index 100% rename from CodeEdit/Utils/Extensions/URL/URL+Identifiable.swift rename to CodeEdit/WorkspaceWindow/OpenQuickly/URL+Identifiable.swift diff --git a/CodeEdit/Features/Commands/Views/QuickActionsView.swift b/CodeEdit/WorkspaceWindow/QuickActions/QuickActionsView.swift similarity index 91% rename from CodeEdit/Features/Commands/Views/QuickActionsView.swift rename to CodeEdit/WorkspaceWindow/QuickActions/QuickActionsView.swift index 12d60e1148..fae65f11bf 100644 --- a/CodeEdit/Features/Commands/Views/QuickActionsView.swift +++ b/CodeEdit/WorkspaceWindow/QuickActions/QuickActionsView.swift @@ -1,11 +1,13 @@ // -// CommandPaletteView.swift +// QuickActionsView.swift // CodeEdit // // Created by Alex Sinelnikov on 24.05.2022. // import SwiftUI +import CodeEditUI +import CodeEditCore /// Quick actions view struct QuickActionsView: View { @@ -15,8 +17,6 @@ struct QuickActionsView: View { @ObservedObject private var state: QuickActionsViewModel - @ObservedObject private var commandManager: CommandManager = .shared - @State private var monitor: Any? @State private var selectedItem: Command? @@ -26,7 +26,7 @@ struct QuickActionsView: View { init(state: QuickActionsViewModel, closePalette: @escaping () -> Void) { self.state = state self.closePalette = closePalette - state.filteredCommands = commandManager.commands + state.reset() } func callHandler(command: Command) { diff --git a/CodeEdit/Features/Commands/ViewModels/QuickActionsViewModel.swift b/CodeEdit/WorkspaceWindow/QuickActions/QuickActionsViewModel.swift similarity index 74% rename from CodeEdit/Features/Commands/ViewModels/QuickActionsViewModel.swift rename to CodeEdit/WorkspaceWindow/QuickActions/QuickActionsViewModel.swift index a796c2aaa7..ada762e39e 100644 --- a/CodeEdit/Features/Commands/ViewModels/QuickActionsViewModel.swift +++ b/CodeEdit/WorkspaceWindow/QuickActions/QuickActionsViewModel.swift @@ -1,16 +1,19 @@ // -// CommandPaletteViewModel.swift +// QuickActionsViewModel.swift // CodeEdit // // Created by Alex on 25.05.2022. // import SwiftUI +import CodeEditCore /// Simple state class for command palette view. Contains currently selected command, /// query text and list of filtered commands final class QuickActionsViewModel: ObservableObject { + private let commandManager: CommandManaging + @Published var commandQuery: String = "" @Published var selected: Command? @@ -19,20 +22,22 @@ final class QuickActionsViewModel: ObservableObject { @Published var filteredCommands: [Command] = [] - init() {} + init(commandManager: CommandManaging) { + self.commandManager = commandManager + } func reset() { commandQuery = "" selected = nil - filteredCommands = CommandManager.shared.commands + filteredCommands = commandManager.commands } func fetchMatchingCommands(val: String) { if val == "" { - self.filteredCommands = CommandManager.shared.commands + self.filteredCommands = commandManager.commands return } - self.filteredCommands = CommandManager.shared.commands.filter { $0.title.localizedCaseInsensitiveContains(val) } + self.filteredCommands = commandManager.commands.filter { $0.title.localizedCaseInsensitiveContains(val) } self.selected = self.filteredCommands.first } diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/AddCETaskView.swift b/CodeEdit/WorkspaceWindow/Settings/AddCETaskView.swift similarity index 86% rename from CodeEdit/Features/CEWorkspaceSettings/Views/AddCETaskView.swift rename to CodeEdit/WorkspaceWindow/Settings/AddCETaskView.swift index 2863e90e46..8c055d9e86 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/AddCETaskView.swift +++ b/CodeEdit/WorkspaceWindow/Settings/AddCETaskView.swift @@ -6,20 +6,18 @@ // import SwiftUI +import CodeEditCore struct AddCETaskView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings - @StateObject var newTask: CETask + @State private var newTask = CETask(target: "My Mac") - init() { - self._newTask = StateObject(wrappedValue: CETask(target: "My Mac")) - } var body: some View { VStack(spacing: 0) { - CETaskFormView(task: newTask) + TaskFormView(task: $newTask) Divider() HStack { Button { diff --git a/CodeEdit/WorkspaceWindow/Settings/CEWorkspaceSettings+TasksConfigurationProviding.swift b/CodeEdit/WorkspaceWindow/Settings/CEWorkspaceSettings+TasksConfigurationProviding.swift new file mode 100644 index 0000000000..b142bfbdb7 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Settings/CEWorkspaceSettings+TasksConfigurationProviding.swift @@ -0,0 +1,17 @@ +// +// CEWorkspaceSettings+TasksConfigurationProviding.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +import Combine +import CodeEditCore + +extension CEWorkspaceSettings: TasksConfigurationProviding { + var tasks: [CETask] { settings.tasks } + + var tasksPublisher: AnyPublisher<[CETask], Never> { + $settings.map(\.tasks).eraseToAnyPublisher() + } +} diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift b/CodeEdit/WorkspaceWindow/Settings/CEWorkspaceSettings.swift similarity index 98% rename from CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift rename to CodeEdit/WorkspaceWindow/Settings/CEWorkspaceSettings.swift index 21d5f661a9..48845e190e 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift +++ b/CodeEdit/WorkspaceWindow/Settings/CEWorkspaceSettings.swift @@ -1,5 +1,5 @@ // -// CEWorkspaceSettingsManager.swift +// CEWorkspaceSettings.swift // CodeEdit // // Created by Axel Martinez on 27/3/24. @@ -7,6 +7,7 @@ import SwiftUI import Combine +import CodeEditCore /// The CodeEdit workspace settings model. final class CEWorkspaceSettings: ObservableObject { diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift b/CodeEdit/WorkspaceWindow/Settings/EditCETaskView.swift similarity index 63% rename from CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift rename to CodeEdit/WorkspaceWindow/Settings/EditCETaskView.swift index 8d12b39f5f..888b6e6ad7 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift +++ b/CodeEdit/WorkspaceWindow/Settings/EditCETaskView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CETerminal +import CodeEditCore struct EditCETaskView: View { @Environment(\.dismiss) @@ -13,22 +15,30 @@ struct EditCETaskView: View { @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings @EnvironmentObject var taskManager: TaskManager - @ObservedObject var task: CETask - let selectedTaskIndex: Int + /// A self-owned draft of the task being edited. Committed back into the settings on "Done". + /// Editing a draft (rather than binding into `settings.tasks` by index) avoids an + /// out-of-bounds crash when the underlying array changes — e.g. on delete. + @State private var task: CETask + private let taskID: UUID + + init(task: CETask) { + self._task = State(initialValue: task) + self.taskID = task.id + } var body: some View { VStack(spacing: 0) { - CETaskFormView(task: task) + TaskFormView(task: $task) Divider() HStack { Button(role: .destructive) { do { workspaceSettingsManager.settings.tasks.removeAll(where: { - $0.id == task.id + $0.id == taskID }) try workspaceSettingsManager.savePreferences() - taskManager.deleteTask(taskID: task.id) + taskManager.deleteTask(taskID: taskID) self.dismiss() } catch { NSAlert(error: error).runModal() @@ -43,6 +53,11 @@ struct EditCETaskView: View { Button { do { + if let index = workspaceSettingsManager.settings.tasks.firstIndex(where: { + $0.id == taskID + }) { + workspaceSettingsManager.settings.tasks[index] = task + } try workspaceSettingsManager.savePreferences() self.dismiss() } catch { diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/EnvironmentVariableListItem.swift b/CodeEdit/WorkspaceWindow/Settings/EnvironmentVariableListItem.swift similarity index 99% rename from CodeEdit/Features/CEWorkspaceSettings/Views/EnvironmentVariableListItem.swift rename to CodeEdit/WorkspaceWindow/Settings/EnvironmentVariableListItem.swift index e16bd5c763..0be2fab580 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/EnvironmentVariableListItem.swift +++ b/CodeEdit/WorkspaceWindow/Settings/EnvironmentVariableListItem.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore struct EnvironmentVariableListItem: View { @FocusState private var isKeyFocused: Bool diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift b/CodeEdit/WorkspaceWindow/Settings/TaskFormView.swift similarity index 96% rename from CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift rename to CodeEdit/WorkspaceWindow/Settings/TaskFormView.swift index 6e7f84057c..c3f7b6cba3 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift +++ b/CodeEdit/WorkspaceWindow/Settings/TaskFormView.swift @@ -1,15 +1,17 @@ // -// CETaskFormView.swift +// TaskFormView.swift // CodeEdit // // Created by Tommy Ludwig on 01.07.24. // import SwiftUI +import CodeEditUI +import CodeEditCore -struct CETaskFormView: View { +struct TaskFormView: View { @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings - @ObservedObject var task: CETask + @Binding var task: CETask @State private var selectedEnvID: UUID? var body: some View { diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsTaskListView.swift b/CodeEdit/WorkspaceWindow/Settings/WorkspaceSettingsTaskListView.swift similarity index 81% rename from CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsTaskListView.swift rename to CodeEdit/WorkspaceWindow/Settings/WorkspaceSettingsTaskListView.swift index 331bc2345b..0bef09eaec 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsTaskListView.swift +++ b/CodeEdit/WorkspaceWindow/Settings/WorkspaceSettingsTaskListView.swift @@ -1,28 +1,28 @@ // -// CEWorkspaceSettingsTaskListView.swift +// WorkspaceSettingsTaskListView.swift // CodeEdit // // Created by Tommy Ludwig on 01.07.24. // import SwiftUI +import CETerminal +import CodeEditCore -struct CEWorkspaceSettingsTaskListView: View { +struct WorkspaceSettingsTaskListView: View { @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings @EnvironmentObject var taskManager: TaskManager - @ObservedObject var settings: CEWorkspaceSettingsData - @Binding var selectedTaskID: UUID? @Binding var showAddTaskSheet: Bool var body: some View { - if settings.tasks.isEmpty { + if workspaceSettingsManager.settings.tasks.isEmpty { Text("No tasks") .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) } else { - ForEach(settings.tasks) { task in + ForEach(workspaceSettingsManager.settings.tasks) { task in TaskTile(task: task) .contentShape(Rectangle()) .onTapGesture { @@ -37,7 +37,7 @@ struct CEWorkspaceSettingsTaskListView: View { Text("Edit") } Button { - settings.tasks.removeAll { $0.id == task.id } + workspaceSettingsManager.settings.tasks.removeAll { $0.id == task.id } try? workspaceSettingsManager.savePreferences() taskManager.deleteTask(taskID: task.id) } label: { @@ -48,9 +48,8 @@ struct CEWorkspaceSettingsTaskListView: View { } } - // Every task as to be observed individually private struct TaskTile: View { - @ObservedObject var task: CETask + let task: CETask var body: some View { HStack { Text(task.name) diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift b/CodeEdit/WorkspaceWindow/Settings/WorkspaceSettingsView.swift similarity index 84% rename from CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift rename to CodeEdit/WorkspaceWindow/Settings/WorkspaceSettingsView.swift index 451ed2a38f..212038b783 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift +++ b/CodeEdit/WorkspaceWindow/Settings/WorkspaceSettingsView.swift @@ -1,17 +1,17 @@ // -// CEWorkspaceSettingsView.swift +// WorkspaceSettingsView.swift // CodeEdit // // Created by Tommy Ludwig on 01.07.24. // import SwiftUI +import CodeEditCore -struct CEWorkspaceSettingsView: View { +struct WorkspaceSettingsView: View { var dismiss: () -> Void @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings - @EnvironmentObject var workspace: WorkspaceDocument @State var selectedTaskID: UUID? @State var showAddTaskSheet: Bool = false @@ -31,8 +31,7 @@ struct CEWorkspaceSettingsView: View { } Section { - CEWorkspaceSettingsTaskListView( - settings: workspaceSettingsManager.settings, + WorkspaceSettingsTaskListView( selectedTaskID: $selectedTaskID, showAddTaskSheet: $showAddTaskSheet ) @@ -71,8 +70,7 @@ struct CEWorkspaceSettingsView: View { $0.id == selectedTaskID }) { EditCETaskView( - task: workspaceSettingsManager.settings.tasks[selectedTaskIndex], - selectedTaskIndex: selectedTaskIndex + task: workspaceSettingsManager.settings.tasks[selectedTaskIndex] ) } else { AddCETaskView() @@ -82,5 +80,5 @@ struct CEWorkspaceSettingsView: View { } #Preview { - CEWorkspaceSettingsView(dismiss: { print("Dismiss") }) + WorkspaceSettingsView(dismiss: { print("Dismiss") }) } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarIcon.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarIcon.swift similarity index 98% rename from CodeEdit/Features/StatusBar/Views/StatusBarIcon.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarIcon.swift index 0df163ba9e..c34415189d 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarIcon.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarIcon.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI /// Accessory icon view for status bar. struct StatusBarIcon: View { diff --git a/CodeEdit/Features/StatusBar/Models/ImageDimensions.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/ImageDimensions.swift similarity index 100% rename from CodeEdit/Features/StatusBar/Models/ImageDimensions.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/ImageDimensions.swift diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarBreakpointButton.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarBreakpointButton.swift similarity index 100% rename from CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarBreakpointButton.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarBreakpointButton.swift diff --git a/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarCursorPositionLabel.swift new file mode 100644 index 0000000000..3a6e37da4b --- /dev/null +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -0,0 +1,96 @@ +// +// StatusBarCursorPositionLabel.swift +// CodeEdit +// +// Created by Lukas Pistrol on 22.03.22. +// + +import SwiftUI +import CodeEditCore +import CodeEditUI + +struct StatusBarCursorPositionLabel: View { + @Environment(\.activeCursorState) + private var activeCursorState + + @Environment(\.modifierKeys) + private var modifierKeys + @Environment(\.controlActiveState) + private var controlActive + + @EnvironmentObject private var statusBarViewModel: StatusBarViewModel + + @State private var cursorPositions: [EditorCursorPosition] = [] + + var body: some View { + Group { + if cursorPositions.isEmpty { + Text("").accessibilityLabel("No Selection") + } else { + Text(getLabel()) + .font(statusBarViewModel.statusBarFont) + .foregroundColor(foregroundColor) + .lineLimit(1) + } + } + .fixedSize() + .accessibilityIdentifier("CursorPositionLabel") + .accessibilityAddTraits(.updatesFrequently) + .onHover { setHoverCursor($0) } + .onReceive(activeCursorState.cursorPositionsPublisher) { newValue in + self.cursorPositions = newValue + } + } + + private var foregroundColor: Color { + if controlActive == .inactive { + Color(nsColor: .disabledControlTextColor) + } else { + Color(nsColor: .secondaryLabelColor) + } + } + + /// Finds the number of lines contained by a range in the currently active document. + /// - Parameter range: The range to query. + /// - Returns: The number of lines in the range. + private func getLines(_ range: NSRange) -> Int { + activeCursorState.linesInRange(range) + } + + /// Create a label string for cursor positions. + /// - Returns: A string describing the user's location in a document. + private func getLabel() -> String { + if cursorPositions.isEmpty { + return "" + } + + // More than one selection, display the number of selections. + if cursorPositions.count > 1 { + return "\(cursorPositions.count) selected ranges" + } + + // If the selection is more than just a cursor, return the length. + if cursorPositions[0].range.length > 0 { + // When the option key is pressed display the character range. + if modifierKeys.contains(.option) { + return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)" + } + + let lineCount = getLines(cursorPositions[0].range) + + if lineCount > 1 { + return "\(lineCount) lines" + } + + return "\(cursorPositions[0].range.length) characters" + } + + // When the option key is pressed display the character offset. + if modifierKeys.contains(.option) { + return "Char: \(cursorPositions[0].range.location) Len: 0" + } + + // When there's a single cursor, display the line and column. + return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)" + } +} diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarEncodingSelector.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarEncodingSelector.swift similarity index 89% rename from CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarEncodingSelector.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarEncodingSelector.swift index c64212461f..11e1c08410 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarEncodingSelector.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarEncodingSelector.swift @@ -16,6 +16,6 @@ struct StatusBarEncodingSelector: View { Text("UTF 8") } .menuStyle(StatusBarMenuStyle()) - .onHover { isHovering($0) } + .onHover { setHoverCursor($0) } } } diff --git a/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarFileInfoView.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarFileInfoView.swift new file mode 100644 index 0000000000..aca59a26a2 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarFileInfoView.swift @@ -0,0 +1,71 @@ +// +// StatusBarFileInfoView.swift +// CodeEdit +// +// Created by Paul Ebose on 2024/5/12. +// + +import SwiftUI +import AppKit +import CodeEditCore +import UniformTypeIdentifiers + +struct StatusBarFileInfoView: View { + + @EnvironmentObject private var statusBarViewModel: StatusBarViewModel + @Environment(\.activeEditorState) + private var activeEditorState + + @State private var fileSize: Int? + @State private var dimensions: ImageDimensions? + + private let dimensionsNumberStyle = IntegerFormatStyle(locale: Locale(identifier: "en_US")).grouping(.never) + + var body: some View { + + HStack(spacing: 15) { + + if let dimensions { + let width = dimensionsNumberStyle.format(dimensions.width) + let height = dimensionsNumberStyle.format(dimensions.height) + + Text("\(width) × \(height)") + } + + if let fileSize { + Text(fileSize.formatted(.byteCount(style: .memory))) + } + + } + .font(statusBarViewModel.statusBarFont) + .foregroundStyle(statusBarViewModel.foregroundStyle) + .onReceive(activeEditorState.selectedFilePublisher) { file in + updateFileInfo(for: file) + } + } + + private func updateFileInfo(for file: CEWorkspaceFile?) { + guard let file, + let resourceValues = try? file.url.resourceValues(forKeys: [.contentTypeKey, .fileSizeKey]), + let contentType = resourceValues.contentType, + let newFileSize = resourceValues.fileSize, + !contentType.conforms(to: .text) + else { + fileSize = nil + dimensions = nil + return + } + + fileSize = newFileSize + + if contentType.conforms(to: .image), + let imageReps = NSImage(contentsOf: file.url)?.representations.first { + dimensions = ImageDimensions( + width: imageReps.pixelsWide, + height: imageReps.pixelsHigh + ) + } else { + dimensions = nil + } + } +} diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarIndentSelector.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarIndentSelector.swift similarity index 92% rename from CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarIndentSelector.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarIndentSelector.swift index fdae627cfd..ac86a92df5 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarIndentSelector.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarIndentSelector.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditSettings struct StatusBarIndentSelector: View { @AppSettings(\.textEditing.defaultTabWidth) @@ -33,6 +34,6 @@ struct StatusBarIndentSelector: View { Text("\(defaultTabWidth) Spaces") } .menuStyle(StatusBarMenuStyle()) - .onHover { isHovering($0) } + .onHover { setHoverCursor($0) } } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarLineEndSelector.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarLineEndSelector.swift similarity index 89% rename from CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarLineEndSelector.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarLineEndSelector.swift index bedcf1bb8a..911b82e3a3 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarLineEndSelector.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarLineEndSelector.swift @@ -16,6 +16,6 @@ struct StatusBarLineEndSelector: View { Text("LF") } .menuStyle(StatusBarMenuStyle()) - .onHover { isHovering($0) } + .onHover { setHoverCursor($0) } } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarMenuStyle.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarMenuStyle.swift similarity index 100% rename from CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarMenuStyle.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarMenuStyle.swift diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarToggleUtilityAreaButton.swift similarity index 86% rename from CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarToggleUtilityAreaButton.swift index c035241596..7ab8441ecb 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/StatusBarToggleUtilityAreaButton.swift @@ -6,11 +6,15 @@ // import SwiftUI +import CodeEditUI internal struct StatusBarToggleUtilityAreaButton: View { @Environment(\.controlActiveState) var controlActiveState + @Environment(\.commandManager) + private var commandManager + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel internal var body: some View { @@ -22,10 +26,10 @@ internal struct StatusBarToggleUtilityAreaButton: View { .buttonStyle(.icon) .keyboardShortcut("Y", modifiers: [.command, .shift]) .help(utilityAreaViewModel.isCollapsed ? "Show the Utility area" : "Hide the Utility area") - .onHover { isHovering($0) } + .onHover { setHoverCursor($0) } .onChange(of: controlActiveState) { _, newValue in if newValue == .key { - CommandManager.shared.addCommand( + commandManager?.addCommand( name: "Toggle Utility Area", title: "Toggle Utility Area", id: "open.drawer", @@ -34,7 +38,7 @@ internal struct StatusBarToggleUtilityAreaButton: View { } } .onAppear { - CommandManager.shared.addCommand( + commandManager?.addCommand( name: "Toggle Utility Area", title: "Toggle Utility Area", id: "open.drawer", diff --git a/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/View+HoverCursor.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/View+HoverCursor.swift new file mode 100644 index 0000000000..b54ae98feb --- /dev/null +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarItems/View+HoverCursor.swift @@ -0,0 +1,29 @@ +// +// View+HoverCursor.swift +// CodeEdit +// +// Created by Lukas Pistrol on 22.03.22. +// + +import SwiftUI + +extension View { + + /// Pushes or pops a cursor to match a hover state. Call from `onHover`. + /// + /// This is not a view modifier — it returns `Void` and mutates the cursor stack + /// as a side effect, so it must be called inside the `onHover` closure rather + /// than chained onto a view. + /// - Parameters: + /// - isHovering: The `onHover()` value. + /// - isDragging: Indicates that dragging is happening. If true, the cursor is left alone. + /// - cursor: The cursor to display while hovering. + func setHoverCursor(_ isHovering: Bool, isDragging: Bool = false, cursor: NSCursor = .arrow) { + if isDragging { return } + if isHovering { + cursor.push() + } else { + NSCursor.pop() + } + } +} diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarView.swift similarity index 80% rename from CodeEdit/Features/StatusBar/Views/StatusBarView.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarView.swift index 82154bbe8f..f41e2bf625 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI /// # StatusBarView /// @@ -24,13 +25,9 @@ struct StatusBarView: View { @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - static var height: CGFloat { - if #available(macOS 26, *) { - 37.0 - } else { - 29.0 - } - } + /// Read from `LayoutMetrics` rather than declared locally: `CEEditor` insets its content by the + /// same value in three places, and a second source of truth would desync them on macOS 26. + static var height: CGFloat { LayoutMetrics.statusBarHeight } private var trailingPadding: CGFloat { if #available(macOS 26, *) { @@ -50,14 +47,14 @@ struct StatusBarView: View { /// The actual status bar var body: some View { HStack(alignment: .center, spacing: 10) { - ForEach(utilityAreaViewModel.tabItems) { tab in + ForEach(utilityAreaViewModel.tabItems, id: \.id) { tab in + let isSelected = utilityAreaViewModel.selectedTabID == tab.id + let tint: NSColor = isSelected ? .controlAccentColor : .secondaryLabelColor Button { - utilityAreaViewModel.selectedTab = tab + utilityAreaViewModel.selectedTabID = tab.id } label: { Image(systemName: tab.systemImage) - .foregroundStyle(Color( - utilityAreaViewModel.selectedTab == tab ? .controlAccentColor : .secondaryLabelColor - )) + .foregroundStyle(Color(nsColor: tint)) } .buttonStyle(.icon) .help(tab.title) diff --git a/CodeEdit/Features/StatusBar/ViewModels/StatusBarViewModel.swift b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarViewModel.swift similarity index 71% rename from CodeEdit/Features/StatusBar/ViewModels/StatusBarViewModel.swift rename to CodeEdit/WorkspaceWindow/StatusBar/StatusBarViewModel.swift index c7f00ae929..7abca6d29e 100644 --- a/CodeEdit/Features/StatusBar/ViewModels/StatusBarViewModel.swift +++ b/CodeEdit/WorkspaceWindow/StatusBar/StatusBarViewModel.swift @@ -9,12 +9,6 @@ import SwiftUI final class StatusBarViewModel: ObservableObject { - /// The file size of the currently opened file. - @Published var fileSize: Int? - - /// The dimensions (width x height) of the currently opened image. - @Published var dimensions: ImageDimensions? - /// Indicates whether the breakpoint is enabled or not. @Published var isBreakpointEnabled = true diff --git a/CodeEdit/Features/Tasks/Views/StartTaskToolbarButton.swift b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StartTaskToolbarButton.swift similarity index 74% rename from CodeEdit/Features/Tasks/Views/StartTaskToolbarButton.swift rename to CodeEdit/WorkspaceWindow/TaskToolbarItems/StartTaskToolbarButton.swift index 01bbb97498..aff3ce4450 100644 --- a/CodeEdit/Features/Tasks/Views/StartTaskToolbarButton.swift +++ b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StartTaskToolbarButton.swift @@ -6,25 +6,29 @@ // import SwiftUI +import CETerminal struct StartTaskToolbarButton: View { @Environment(\.controlActiveState) private var activeState + @Environment(\.commandManager) + private var commandManager + @ObservedObject var taskManager: TaskManager - @EnvironmentObject var workspace: WorkspaceDocument + @EnvironmentObject var utilityAreaModel: UtilityAreaViewModel var utilityAreaCollapsed: Bool { - workspace.utilityAreaModel?.isCollapsed ?? true + utilityAreaModel.isCollapsed } var body: some View { Button { taskManager.executeActiveTask() if utilityAreaCollapsed { - CommandManager.shared.executeCommand("open.drawer") + commandManager?.executeCommand("open.drawer") } - workspace.utilityAreaModel?.selectedTab = .debugConsole + utilityAreaModel.selectedTabID = PanelTabID.debugConsole taskManager.taskShowingOutput = taskManager.selectedTaskID } label: { Label("Start", systemImage: "play.fill") diff --git a/CodeEdit/Features/Tasks/ToolbarItems/StartTaskToolbarItem.swift b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StartTaskToolbarItem.swift similarity index 65% rename from CodeEdit/Features/Tasks/ToolbarItems/StartTaskToolbarItem.swift rename to CodeEdit/WorkspaceWindow/TaskToolbarItems/StartTaskToolbarItem.swift index 2d2b5d6e1b..ac87b5472d 100644 --- a/CodeEdit/Features/Tasks/ToolbarItems/StartTaskToolbarItem.swift +++ b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StartTaskToolbarItem.swift @@ -6,17 +6,22 @@ // import AppKit +import CETerminal @available(macOS 26, *) final class StartTaskToolbarItem: NSToolbarItem { - private weak var workspace: WorkspaceDocument? + private weak var workspace: Workspace? + private weak var utilityAreaModel: UtilityAreaViewModel? + private let commandManager: CommandManaging private var utilityAreaCollapsed: Bool { - workspace?.utilityAreaModel?.isCollapsed ?? true + utilityAreaModel?.isCollapsed ?? true } - init(workspace: WorkspaceDocument) { + init(workspace: Workspace, utilityAreaModel: UtilityAreaViewModel, commandManager: CommandManaging) { self.workspace = workspace + self.utilityAreaModel = utilityAreaModel + self.commandManager = commandManager super.init(itemIdentifier: NSToolbarItem.Identifier("StartTaskToolbarItem")) image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: nil) @@ -36,9 +41,9 @@ final class StartTaskToolbarItem: NSToolbarItem { taskManager.executeActiveTask() if utilityAreaCollapsed { - CommandManager.shared.executeCommand("open.drawer") + commandManager.executeCommand("open.drawer") } - workspace?.utilityAreaModel?.selectedTab = .debugConsole + utilityAreaModel?.selectedTabID = PanelTabID.debugConsole taskManager.taskShowingOutput = taskManager.selectedTaskID } } diff --git a/CodeEdit/Features/Tasks/Views/StopTaskToolbarButton.swift b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StopTaskToolbarButton.swift similarity index 99% rename from CodeEdit/Features/Tasks/Views/StopTaskToolbarButton.swift rename to CodeEdit/WorkspaceWindow/TaskToolbarItems/StopTaskToolbarButton.swift index 0d4a7bcd98..e53da833d4 100644 --- a/CodeEdit/Features/Tasks/Views/StopTaskToolbarButton.swift +++ b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StopTaskToolbarButton.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CETerminal import Combine struct StopTaskToolbarButton: View { diff --git a/CodeEdit/Features/Tasks/ToolbarItems/StopTaskToolbarItem.swift b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StopTaskToolbarItem.swift similarity index 93% rename from CodeEdit/Features/Tasks/ToolbarItems/StopTaskToolbarItem.swift rename to CodeEdit/WorkspaceWindow/TaskToolbarItems/StopTaskToolbarItem.swift index eaa5148439..6927e53c8b 100644 --- a/CodeEdit/Features/Tasks/ToolbarItems/StopTaskToolbarItem.swift +++ b/CodeEdit/WorkspaceWindow/TaskToolbarItems/StopTaskToolbarItem.swift @@ -6,11 +6,12 @@ // import AppKit +import CETerminal import Combine @available(macOS 26, *) final class StopTaskToolbarItem: NSToolbarItem { - private weak var workspace: WorkspaceDocument? + private weak var workspace: Workspace? private var taskManager: TaskManager? { workspace?.taskManager @@ -21,8 +22,8 @@ final class StopTaskToolbarItem: NSToolbarItem { private var statusListener: AnyCancellable? private var otherListeners: Set = [] - init?(workspace: WorkspaceDocument) { - guard let taskManager = workspace.taskManager else { return nil } + init(workspace: Workspace) { + let taskManager = workspace.taskManager self.workspace = workspace super.init(itemIdentifier: NSToolbarItem.Identifier("StopTaskToolbarItem")) diff --git a/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputActionsView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/TaskOutputActionsView.swift similarity index 98% rename from CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputActionsView.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/TaskOutputActionsView.swift index 34f28a18bf..6e1815f988 100644 --- a/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputActionsView.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/TaskOutputActionsView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CETerminal +import CodeEditUI struct TaskOutputActionsView: View { @ObservedObject var activeTask: CEActiveTask diff --git a/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/TaskOutputView.swift similarity index 95% rename from CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/TaskOutputView.swift index c5325280ee..88a88cb9fa 100644 --- a/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/TaskOutputView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CETerminal struct TaskOutputView: View { @ObservedObject var activeTask: CEActiveTask diff --git a/CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/UtilityAreaDebugView.swift similarity index 99% rename from CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/UtilityAreaDebugView.swift index 996a66cbd3..1546ea1201 100644 --- a/CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/DebugUtility/UtilityAreaDebugView.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CETerminal +import CodeEditSettings +import CodeEditUI struct UtilityAreaDebugView: View { @AppSettings(\.theme.matchAppearance) diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/ExtensionUtilityAreaOutputSource.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/ExtensionUtilityAreaOutputSource.swift diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/InternalDevelopmentOutputSource.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/InternalDevelopmentOutputSource.swift diff --git a/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/LanguageServerLogContainer+UtilityArea.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/LanguageServerLogContainer+UtilityArea.swift new file mode 100644 index 0000000000..4c09568ca9 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/LanguageServerLogContainer+UtilityArea.swift @@ -0,0 +1,28 @@ +// +// LanguageServerLogContainer+UtilityArea.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +import Foundation +import CELSP + +/// Adapts CELSP's log container to the UtilityArea output protocols. Lives app-side so +/// the package stays free of UtilityArea types. +extension LanguageServerLogContainer: UtilityAreaOutputSource {} + +extension LanguageServerLogContainer.LanguageServerMessage: UtilityAreaOutputMessage { + var level: UtilityAreaLogLevel { + switch log.type { + case .error: + .error + case .warning: + .warning + case .info: + .info + case .log: + .debug + } + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaLogLevel.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaLogLevel.swift diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputLogList.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputLogList.swift diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputSource.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputSource.swift diff --git a/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputSourcePicker.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputSourcePicker.swift new file mode 100644 index 0000000000..757558ee1f --- /dev/null +++ b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputSourcePicker.swift @@ -0,0 +1,112 @@ +// +// UtilityAreaOutputSourcePicker.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import CodeEditCore +import CELSP +import SwiftUI +import CodeEditSettings + +struct UtilityAreaOutputSourcePicker: View { + typealias Sources = UtilityAreaOutputView.Sources + + @Environment(\.languageServerListState) + private var languageServerListState + + @Binding var selectedSource: Sources? + + var body: some View { + if let languageServerListState { + ObservingContent(listState: languageServerListState, selectedSource: $selectedSource) + } else { + // Previews: the environment key is nil and the server list is legitimately empty. + Content(runningServers: [], selectedSource: $selectedSource) + } + } + + /// Observes the server list and feeds it to ``Content`` as plain data. A separate view because + /// `@ObservedObject` cannot hold the optional environment value directly. + private struct ObservingContent: View { + @ObservedObject var listState: LanguageServerListState + @Binding var selectedSource: Sources? + + var body: some View { + Content(runningServers: listState.runningServers, selectedSource: $selectedSource) + } + } + + private struct Content: View { + @Environment(\.workspaceFileURL) + private var workspaceFileURL + + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + + let runningServers: [RunningLanguageServer] + + @Binding var selectedSource: Sources? + + @ObservedObject var extensionManager = ExtensionManager.shared + + private var languageServerClients: [RunningLanguageServer] { + runningServers + .filter { $0.workspacePath == workspaceFileURL?.absolutePath } + .sorted(by: { $0.languageId.rawValue < $1.languageId.rawValue }) + } + + var body: some View { + Picker("Output Source", selection: $selectedSource) { + if selectedSource == nil { + Text("No Selected Output Source") + .italic() + .tag(Sources?.none) + Divider() + } + + if languageServerClients.isEmpty { + Text("No Language Servers") + } else { + ForEach(languageServerClients, id: \.languageId) { server in + Text(Sources.languageServer(server.logContainer).title) + .tag(Sources.languageServer(server.logContainer)) + } + } + + Divider() + + if extensionManager.extensions.isEmpty { + Text("No Extensions") + } else { + ForEach(extensionManager.extensions) { extensionInfo in + Text(Sources.extensions(.init(extensionInfo: extensionInfo)).title) + .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) + } + } + + if showInternalDevelopmentInspector { + Divider() + Text(Sources.devOutput.title) + .tag(Sources.devOutput) + } + } + .buttonStyle(.borderless) + .labelsHidden() + .controlSize(.small) + .onAppear { + selectDefaultSourceIfNeeded() + } + .onChange(of: languageServerClients.map(\.id)) { _, _ in + selectDefaultSourceIfNeeded() + } + } + + private func selectDefaultSourceIfNeeded() { + if selectedSource == nil, let client = languageServerClients.first { + selectedSource = Sources.languageServer(client.logContainer) + } + } + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputView.swift similarity index 99% rename from CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputView.swift index 94bd5a5a7c..f191b20093 100644 --- a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/OutputUtility/UtilityAreaOutputView.swift @@ -5,6 +5,7 @@ // Created by Austin Condiff on 5/25/23. // +import CELSP import SwiftUI import LogStream diff --git a/CodeEdit/Features/UtilityArea/Views/PaneToolbar.swift b/CodeEdit/WorkspaceWindow/UtilityArea/PaneToolbar.swift similarity index 99% rename from CodeEdit/Features/UtilityArea/Views/PaneToolbar.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/PaneToolbar.swift index a6c67238dc..10ed9b355d 100644 --- a/CodeEdit/Features/UtilityArea/Views/PaneToolbar.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/PaneToolbar.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct PaneToolbar: View { @ViewBuilder var content: Content diff --git a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTerminal.swift b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminal.swift similarity index 97% rename from CodeEdit/Features/UtilityArea/Models/UtilityAreaTerminal.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminal.swift index abf9d780d1..ab66a4078d 100644 --- a/CodeEdit/Features/UtilityArea/Models/UtilityAreaTerminal.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminal.swift @@ -6,6 +6,7 @@ // import Foundation +import CETerminal final class UtilityAreaTerminal: ObservableObject, Identifiable, Equatable { let id: UUID diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalPicker.swift b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalPicker.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalPicker.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalPicker.swift diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift similarity index 94% rename from CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift index 1a9fb53ffe..1a9063b1c2 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift @@ -6,11 +6,14 @@ // import SwiftUI +import CETerminal /// The view that displays the list of available terminals in the utility area. /// See ``UtilityAreaTerminalView`` for use. struct UtilityAreaTerminalSidebar: View { - @EnvironmentObject private var workspace: WorkspaceDocument + @Environment(\.workspaceFileURL) + private var workspaceFileURL + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel var body: some View { @@ -34,29 +37,29 @@ struct UtilityAreaTerminalSidebar: View { .accentColor(.secondary) .contextMenu { Button("New Terminal") { - utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) + utilityAreaViewModel.addTerminal(rootURL: workspaceFileURL) } Menu("New Terminal With Profile") { Button("Default") { - utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) + utilityAreaViewModel.addTerminal(rootURL: workspaceFileURL) } Divider() ForEach(Shell.allCases, id: \.self) { shell in Button(shell.rawValue) { - utilityAreaViewModel.addTerminal(shell: shell, rootURL: workspace.fileURL) + utilityAreaViewModel.addTerminal(shell: shell, rootURL: workspaceFileURL) } } } } .onChange(of: utilityAreaViewModel.terminals) { _, newValue in if newValue.isEmpty { - utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) + utilityAreaViewModel.addTerminal(rootURL: workspaceFileURL) } } .paneToolbar { PaneToolbarSection { Button { - utilityAreaViewModel.addTerminal(rootURL: workspace.fileURL) + utilityAreaViewModel.addTerminal(rootURL: workspaceFileURL) } label: { Image(systemName: "plus") } diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift similarity index 97% rename from CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift index 9e6d047ca3..209c22f2d0 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift @@ -1,11 +1,14 @@ // -// UtilityAreaTerminal.swift +// UtilityAreaTerminalView.swift // CodeEdit // // Created by Austin Condiff on 5/25/23. // import SwiftUI +import CETerminal +import CodeEditSettings +import CodeEditUI import Cocoa struct UtilityAreaTerminalView: View { @@ -25,7 +28,8 @@ struct UtilityAreaTerminalView: View { @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject private var workspace: WorkspaceDocument + @Environment(\.workspaceFileURL) + private var workspaceFileURL @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel @@ -162,7 +166,7 @@ struct UtilityAreaTerminalView: View { UtilityAreaTerminalSidebar() } .onAppear { - guard let workspaceURL = workspace.fileURL else { + guard let workspaceURL = workspaceFileURL else { assertionFailure("Workspace does not have a file URL.") return } diff --git a/CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaClearButton.swift b/CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaClearButton.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaClearButton.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaClearButton.swift diff --git a/CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaFilterTextField.swift b/CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaFilterTextField.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaFilterTextField.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaFilterTextField.swift diff --git a/CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaMaximizeButton.swift b/CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaMaximizeButton.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaMaximizeButton.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaMaximizeButton.swift diff --git a/CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaSplitTerminalButton.swift b/CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaSplitTerminalButton.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/Toolbar/UtilityAreaSplitTerminalButton.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/Toolbar/UtilityAreaSplitTerminalButton.swift diff --git a/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaContributions.swift b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaContributions.swift new file mode 100644 index 0000000000..916f68d18d --- /dev/null +++ b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaContributions.swift @@ -0,0 +1,30 @@ +// +// UtilityAreaContributions.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import CodeEditUI +import SwiftUI + +struct TerminalUtilityContribution: WorkspacePanelContribution { + let id = PanelTabID.terminal + let title = "Terminal" + let systemImage = "terminal" + var content: AnyView { AnyView(UtilityAreaTerminalView()) } +} + +struct DebugConsoleUtilityContribution: WorkspacePanelContribution { + let id = PanelTabID.debugConsole + let title = "Debug Console" + let systemImage = "ladybug" + var content: AnyView { AnyView(UtilityAreaDebugView()) } +} + +struct OutputUtilityContribution: WorkspacePanelContribution { + let id = PanelTabID.output + let title = "Output" + let systemImage = "list.bullet.indent" + var content: AnyView { AnyView(UtilityAreaOutputView()) } +} diff --git a/CodeEdit/Features/UtilityArea/Views/UtilityAreaTabView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaTabView.swift similarity index 99% rename from CodeEdit/Features/UtilityArea/Views/UtilityAreaTabView.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaTabView.swift index c3d4a0654a..923e6aca5a 100644 --- a/CodeEdit/Features/UtilityArea/Views/UtilityAreaTabView.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaTabView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct UtilityAreaTabView: View { @ObservedObject var model: UtilityAreaTabViewModel diff --git a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaTabViewModel.swift b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaTabViewModel.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaTabViewModel.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaTabViewModel.swift diff --git a/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaView.swift b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaView.swift new file mode 100644 index 0000000000..d0c7194e27 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaView.swift @@ -0,0 +1,26 @@ +// +// UtilityAreaView.swift +// CodeEdit +// +// Created by Lukas Pistrol on 22.03.22. +// + +import SwiftUI + +struct UtilityAreaView: View { + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + + var body: some View { + WorkspacePanelView( + viewModel: utilityAreaViewModel, + selectedTabID: $utilityAreaViewModel.selectedTabID, + tabItems: $utilityAreaViewModel.tabItems, + sidebarPosition: .side, + darkDivider: true, + padSideItemVertically: true + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Utility Area") + .accessibilityIdentifier("UtilityArea") + } +} diff --git a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaViewModel.swift similarity index 70% rename from CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaViewModel.swift index 0cc075464a..87f40f2f0c 100644 --- a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift +++ b/CodeEdit/WorkspaceWindow/UtilityArea/UtilityAreaViewModel.swift @@ -5,6 +5,9 @@ // Created by Lukas Pistrol on 20.03.22. // +import CodeEditCore +import CodeEditUI +import CETerminal import SwiftUI /// # UtilityAreaViewModel @@ -12,7 +15,7 @@ import SwiftUI /// A model class to host and manage data for the Utility area. class UtilityAreaViewModel: ObservableObject { - @Published var selectedTab: UtilityAreaTab? = .terminal + @Published var selectedTabID: String? = PanelTabID.terminal @Published var terminals: [UtilityAreaTerminal] = [] @@ -30,24 +33,33 @@ class UtilityAreaViewModel: ObservableObject { /// The current height of the drawer. Zero if hidden @Published var currentHeight: Double = 0 - /// The tab bar items for the UtilityAreaView - @Published var tabItems: [UtilityAreaTab] = UtilityAreaTab.allCases + /// The tab bar items for the UtilityAreaView. + /// + /// Injected rather than defaulted: assembly is `@MainActor`, and a property-default expression + /// is evaluated in this non-isolated class's `init`. The owner supplies it, so the list is + /// populated before the first body evaluation — the utility area never paints "No Selection". + @Published var tabItems: [any WorkspacePanelContribution] /// The tab bar view model for UtilityAreaTabView @Published var tabViewModel = UtilityAreaTabViewModel() + /// - Parameter tabItems: The panel's tabs. Defaults to none, for tests that do not exercise them. + init(tabItems: [any WorkspacePanelContribution] = []) { + self.tabItems = tabItems + } + // MARK: - State Restoration - func restoreFromState(_ workspace: WorkspaceDocument) { - isCollapsed = workspace.getFromWorkspaceState(.utilityAreaCollapsed) as? Bool ?? false - currentHeight = workspace.getFromWorkspaceState(.utilityAreaHeight) as? Double ?? 300.0 - isMaximized = workspace.getFromWorkspaceState(.utilityAreaMaximized) as? Bool ?? false + func restoreFromState(_ statePersistence: any WorkspaceStatePersisting) { + isCollapsed = statePersistence.get(.utilityAreaCollapsed) as? Bool ?? false + currentHeight = statePersistence.get(.utilityAreaHeight) as? Double ?? 300.0 + isMaximized = statePersistence.get(.utilityAreaMaximized) as? Bool ?? false } - func saveRestorationState(_ workspace: WorkspaceDocument) { - workspace.addToWorkspaceState(key: .utilityAreaCollapsed, value: isCollapsed) - workspace.addToWorkspaceState(key: .utilityAreaHeight, value: currentHeight) - workspace.addToWorkspaceState(key: .utilityAreaMaximized, value: isMaximized) + func saveRestorationState(_ statePersistence: any WorkspaceStatePersisting) { + statePersistence.set(key: .utilityAreaCollapsed, value: isCollapsed) + statePersistence.set(key: .utilityAreaHeight, value: currentHeight) + statePersistence.set(key: .utilityAreaMaximized, value: isMaximized) } func togglePanel(animation: Bool = true) { @@ -64,7 +76,10 @@ class UtilityAreaViewModel: ObservableObject { func removeTerminals(_ ids: Set) { for (idx, terminal) in terminals.enumerated().reversed() where ids.contains(terminal.id) { - TerminalCache.shared.removeCachedView(terminal.id) + // `UtilityAreaViewModel` isn't statically @MainActor, but is only ever driven from SwiftUI on main. + MainActor.assumeIsolated { + TerminalCache.shared.removeCachedView(terminal.id) + } terminals.remove(at: idx) } @@ -134,8 +149,11 @@ class UtilityAreaViewModel: ObservableObject { let id = UUID() let url = terminals[index].url let shell = terminals[index].shell - if let shellPid = TerminalCache.shared.getTerminalView(replacing)?.process.shellPid { - kill(shellPid, SIGKILL) + // `UtilityAreaViewModel` isn't statically @MainActor, but is only ever driven from SwiftUI on main. + MainActor.assumeIsolated { + if let shellPid = TerminalCache.shared.getTerminalView(replacing)?.process.shellPid { + kill(shellPid, SIGKILL) + } } terminals[index] = UtilityAreaTerminal( @@ -144,7 +162,9 @@ class UtilityAreaViewModel: ObservableObject { title: shell?.rawValue ?? "terminal", shell: shell ) - TerminalCache.shared.removeCachedView(replacing) + MainActor.assumeIsolated { + TerminalCache.shared.removeCachedView(replacing) + } selectedTerminals = [id] return diff --git a/CodeEdit/Features/UtilityArea/Views/View+paneToolbar.swift b/CodeEdit/WorkspaceWindow/UtilityArea/View+paneToolbar.swift similarity index 100% rename from CodeEdit/Features/UtilityArea/Views/View+paneToolbar.swift rename to CodeEdit/WorkspaceWindow/UtilityArea/View+paneToolbar.swift diff --git a/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppCodeFileDocumentDelegate.swift b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppCodeFileDocumentDelegate.swift new file mode 100644 index 0000000000..c6c09d4ecb --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppCodeFileDocumentDelegate.swift @@ -0,0 +1,84 @@ +// +// AppCodeFileDocumentDelegate.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import AppKit +import CEEditor +import CELSP +import CodeEditCore +import CodeEditDocument +import CodeEditSettings +import CodeEditTextView +import SwiftUI + +/// App-side implementation of ``CodeFileDocumentDelegate``. Bridges a packaged +/// `CodeFileDocument` back to the app's `Workspace` undo registry, Settings-injected +/// standalone-window view, and `LSPService` lifecycle notifications. +@MainActor +final class AppCodeFileDocumentDelegate: CodeFileDocumentDelegate { + private let lspService: any LSPServiceProtocol + private let windowManager: WorkspaceWindowManaging + private let languageServices: LanguageServicesProvider + + /// The settings store, so the standalone hosting root below can inject the settings seam. + private let settingsStore: PersistentSettingsStore + + /// The themes in effect. `CodeFileView` reads these as an `@EnvironmentObject`, which traps when + /// missing — and the hosting root below is standalone, so nothing above it can supply them. + private let activeTheme: ActiveTheme + + init( + lspService: any LSPServiceProtocol, + windowManager: WorkspaceWindowManaging, + languageServices: LanguageServicesProvider, + settingsStore: PersistentSettingsStore, + activeTheme: ActiveTheme + ) { + self.settingsStore = settingsStore + self.activeTheme = activeTheme + self.lspService = lspService + self.windowManager = windowManager + self.languageServices = languageServices + } + + /// The **workspace** undo manager for `url`, or `nil` — never a standalone window's. + /// + /// `CodeFileDocument` calls this to register an external change to an open file as one undo + /// mutation. A file shown in a standalone single-file window has its own registry, private to + /// `WindowCodeFileView`, which this cannot reach: there is no workspace to look it up through. + /// + /// Normally the two cannot disagree, because every route to a standalone window + /// (`DocumentOpener`, `WorkspaceWindowManager`'s new-file path, `AppDelegate`'s open handler) + /// tries `openFileInWorkspace(url:)` first and only falls through when it fails — and that check + /// uses the same predicate as `workspace(containing:)`. So if a workspace holds the file there + /// is no standalone window, and if none does this returns `nil` and nothing is registered. + /// + /// The guard is evaluated once, though. Open a loose file, *then* open its parent folder as a + /// workspace, and the standalone window keeps its private registry while this starts finding + /// the workspace's. An external change then registers onto a stack that window does not read. + /// Editing undo inside the window is unaffected. Left as-is deliberately: migrating a window + /// onto a workspace registry, or letting this search every registry, is more machinery than a + /// three-step ordering edge case earns. + func undoManager(forFile url: URL) -> CEUndoManager? { + windowManager.workspace(containing: url)?.undoRegistry.managerIfExists(forFile: url) + } + + func makeWindowContentView(for document: CodeFileDocument) -> NSView { + NSHostingView(rootView: SettingsInjector(store: settingsStore) { + WindowCodeFileView(codeFile: document) + .environment(\.languageServices, languageServices) + .environmentObject(activeTheme) + }) + } + + func documentDidOpen(_ document: CodeFileDocument) { + lspService.openDocument(document) + } + + func documentDidClose(at url: URL) { + lspService.closeDocument(url) + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppErrorNotifier.swift b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppErrorNotifier.swift new file mode 100644 index 0000000000..1a530e1534 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppErrorNotifier.swift @@ -0,0 +1,30 @@ +// +// AppErrorNotifier.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +import CodeEditCore +import CENotifications + +/// App-shell binding of the `ErrorNotifying` seam onto the notification system. +final class AppErrorNotifier: ErrorNotifying { + private let notificationManager: NotificationManaging + + init(notificationManager: NotificationManaging) { + self.notificationManager = notificationManager + } + + @MainActor + func postError(title: String, description: String) { + notificationManager.post( + iconSymbol: "xmark.circle", + iconColor: .clear, + title: title, + description: description, + actionButtonTitle: "Done", + action: {} + ) + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppFileRelocator.swift b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppFileRelocator.swift new file mode 100644 index 0000000000..2730c83bb9 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppFileRelocator.swift @@ -0,0 +1,27 @@ +// +// AppFileRelocator.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import CodeEditCore +import CEWorkspaceFileManager + +/// App-shell binding of the `FileRelocator` command. Resolves the workspace that +/// owns the file and delegates to `FileMover`, which moves the file and +/// reconciles open tabs. +final class AppFileRelocator: FileRelocator { + private let windowManager: WorkspaceWindowManaging + + init(windowManager: WorkspaceWindowManaging) { + self.windowManager = windowManager + } + + @MainActor + func relocate(file: CEWorkspaceFile, to destination: URL) throws -> CEWorkspaceFile? { + guard let workspace = windowManager.workspace(containing: file.url) else { return nil } + return try FileMover().execute(file: file, to: destination, in: workspace) + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppWorkspaceFileOpener.swift b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppWorkspaceFileOpener.swift new file mode 100644 index 0000000000..4c2e25b6cb --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppWorkspaceFileOpener.swift @@ -0,0 +1,25 @@ +// +// AppWorkspaceFileOpener.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import Foundation +import CodeEditCore + +/// App-shell binding of the `WorkspaceFileOpener` command interface. +/// Delegates to `WorkspaceWindowManager.openFileInWorkspace(url:)`, which maps the +/// URL to the workspace that owns it, opens the tab, and focuses that workspace. +final class AppWorkspaceFileOpener: WorkspaceFileOpener { + private let windowManager: WorkspaceWindowManaging + + init(windowManager: WorkspaceWindowManaging) { + self.windowManager = windowManager + } + + @MainActor + func openFile(at url: URL) { + _ = windowManager.openFileInWorkspace(url: url) + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppWorkspaceNavigator.swift b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppWorkspaceNavigator.swift new file mode 100644 index 0000000000..d52f247db4 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Adapters/AppWorkspaceNavigator.swift @@ -0,0 +1,42 @@ +// +// AppWorkspaceNavigator.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import CodeEditCore +import CEWorkspaceFileManager +import CEEditor + +/// App-shell binding of the `WorkspaceNavigator` command interface. +/// Delegates to `WorkspaceWindowManager.openFileInWorkspace(url:asTemporary:)`, which maps the +/// file's URL to the workspace that owns it, opens the tab, and focuses that workspace. +final class AppWorkspaceNavigator: WorkspaceNavigator { + private let windowManager: WorkspaceWindowManaging + + init(windowManager: WorkspaceWindowManaging) { + self.windowManager = windowManager + } + + @MainActor + func open(file: CEWorkspaceFile, asTemporary: Bool) { + _ = windowManager.openFileInWorkspace(url: file.url, asTemporary: asTemporary) + } + + @MainActor + func open(fileAt url: URL, asTemporary: Bool) { + _ = windowManager.openFileInWorkspace(url: url, asTemporary: asTemporary) + } + + @MainActor + func reveal(file: CEWorkspaceFile) { + windowManager.workspace(containing: file.url)?.revealRequests.send(file) + } + + @MainActor + func closeTab(file: CEWorkspaceFile) { + windowManager.workspace(containing: file.url)?.editorManager.editorLayout.closeAllTabs(of: file) + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Environment+Workspace.swift b/CodeEdit/WorkspaceWindow/Workspace/Environment+Workspace.swift new file mode 100644 index 0000000000..47c76af986 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Environment+Workspace.swift @@ -0,0 +1,88 @@ +// +// Environment+Workspace.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 03/07/2026. +// + +import SwiftUI +import CEWorkspaceFileManager +import CodeEditCore + +private struct ActiveEditorStateKey: EnvironmentKey { + static let defaultValue: ActiveEditorState = NoOpActiveEditorState() +} + +private struct ActiveCursorStateKey: EnvironmentKey { + static let defaultValue: ActiveCursorState = NoOpActiveCursorState() +} + +private struct FileEditorOverridesKey: EnvironmentKey { + static let defaultValue: FileEditorOverrides = NoOpFileEditorOverrides() +} + +private struct WorkspaceFileManagerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: CEWorkspaceFileManager? = nil +} + +private struct WorkspaceFileURLKey: EnvironmentKey { + static let defaultValue: URL? = nil +} + +private struct WorkspaceStatePersistenceKey: EnvironmentKey { + static let defaultValue: (any WorkspaceStatePersisting)? = nil +} + +private struct WorkspaceKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: Workspace? = nil +} + +private struct FilePreviewFactoryKey: EnvironmentKey { + static let defaultValue: (CEWorkspaceFile) -> AnyView = { _ in AnyView(EmptyView()) } +} + +extension EnvironmentValues { + /// The workspace owning this view tree. `nil` only in previews or a mis-wired tree — + /// `CodeEditSplitViewController` populates it for every real workspace window. + var workspace: Workspace? { + get { self[WorkspaceKey.self] } + set { self[WorkspaceKey.self] = newValue } + } + + /// The concrete workspace file manager, for app-shell views that need the mutating + /// API (create/rename). Feature packages use `\.workspaceFileProvider` (CEEditor) instead. + var workspaceFileManager: CEWorkspaceFileManager? { + get { self[WorkspaceFileManagerKey.self] } + set { self[WorkspaceFileManagerKey.self] = newValue } + } + + var workspaceFileURL: URL? { + get { self[WorkspaceFileURLKey.self] } + set { self[WorkspaceFileURLKey.self] = newValue } + } + + var workspaceStatePersistence: (any WorkspaceStatePersisting)? { + get { self[WorkspaceStatePersistenceKey.self] } + set { self[WorkspaceStatePersistenceKey.self] = newValue } + } + + var activeEditorState: ActiveEditorState { + get { self[ActiveEditorStateKey.self] } + set { self[ActiveEditorStateKey.self] = newValue } + } + + var activeCursorState: ActiveCursorState { + get { self[ActiveCursorStateKey.self] } + set { self[ActiveCursorStateKey.self] = newValue } + } + + var fileEditorOverrides: FileEditorOverrides { + get { self[FileEditorOverridesKey.self] } + set { self[FileEditorOverridesKey.self] = newValue } + } + + var filePreview: (CEWorkspaceFile) -> AnyView { + get { self[FilePreviewFactoryKey.self] } + set { self[FilePreviewFactoryKey.self] = newValue } + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Files/CEWorkspaceFile+Presentation.swift b/CodeEdit/WorkspaceWindow/Workspace/Files/CEWorkspaceFile+Presentation.swift new file mode 100644 index 0000000000..f8ac1b02cb --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Files/CEWorkspaceFile+Presentation.swift @@ -0,0 +1,78 @@ +// +// CEWorkspaceFile+Presentation.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import SwiftUI +import CodeEditCore +import CodeEditSettings +import CodeEditUI + +extension CEWorkspaceFile { + // MARK: Icons + + /// Symbol + tint for this file or folder, from ``CodeEditUI/FileIcon``. + var iconSpec: FileIconSpec { + isFolder + ? FileIcon.folderSpec( + isEmpty: isEmptyFolder, + isRoot: parent == nil, + isCodeEditDirectory: name == ".codeedit" + ) + : FileIcon.spec(for: url) + } + + var icon: Image { iconSpec.image } + var nsIcon: NSImage { iconSpec.nsImage } + var iconColor: Color { iconSpec.color } + + // MARK: Intents + + /// Reveal the file/folder in Finder. + func showInFinder() { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + /// Open the file/folder with the system default application. + func openWithExternalEditor() { + NSWorkspace.shared.open(url) + } + + // MARK: Display name (user preference driven) + + /// A display name honoring the user's file-extension-visibility preference. + /// + /// Matches on the file's real extension. It used to compare `type.rawValue`, which + /// silently failed twice over: the raw value for `.txt` was `"text"`, so a user + /// entering `txt` never matched, and any extension absent from the `FileType` enum + /// fell back to `.txt` and so reported itself as `"text"`. + /// - Parameter prefs: The general settings, passed in by value. This used to read the settings + /// singleton; an extension on a domain type has no injection channel, so its one caller-visible + /// dependency became a parameter instead. + func labelFileName(_ prefs: GeneralSettings) -> String { + switch prefs.fileExtensionsVisibility { + case .hideAll: + return self.fileName(typeHidden: true) + case .showAll: + return self.fileName(typeHidden: false) + case .showOnly: + return self.fileName(typeHidden: !prefs.shownFileExtensions.extensions.contains(url.pathExtension)) + case .hideOnly: + return self.fileName(typeHidden: prefs.hiddenFileExtensions.extensions.contains(url.pathExtension)) + } + } + + func validateFileName(for newName: String, prefs: GeneralSettings) -> Bool { + guard newName != labelFileName(prefs) && + !newName.isEmpty && + newName.isValidFilename && + !FileManager.default.fileExists( + atPath: self.url.deletingLastPathComponent().appending(path: newName).path + ) else { + return false + } + return true + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Files/FileDropHandler.swift b/CodeEdit/WorkspaceWindow/Workspace/Files/FileDropHandler.swift new file mode 100644 index 0000000000..c1afc14862 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Files/FileDropHandler.swift @@ -0,0 +1,62 @@ +// +// FileDropHandler.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/04/26. +// + +import Foundation +import CEWorkspaceFileManager +import CodeEditCore + +/// Resolves dropped file URLs into copy/move operations, handling source resolution and replace conflicts. +@MainActor +final class FileDropHandler { + + private let fileManager: FileManager = .default + + struct Operation { + let source: CEWorkspaceFile + let destination: URL + let isCopy: Bool + } + + /// Returns the operations that should be executed for the given dropped URLs. + /// Filters out drops onto self/parent. Asks `confirmReplace` (and removes the existing file) + /// when the destination already exists. + func execute( + urls: [URL], + destinationParent: CEWorkspaceFile, + isCopyOperation: Bool, + in workspace: Workspace, + confirmReplace: (String) -> Bool + ) throws -> [Operation] { + let destParentURL = destinationParent.url + var operations: [Operation] = [] + + for url in urls { + let destURL = destParentURL.appending(path: url.lastPathComponent) + + // Cancel dropping a file on itself or in its own parent directory + if url == destURL || url == destParentURL { + continue + } + + // Resolve the source: either an existing workspace file, or treat as external + let source = workspace.workspaceFileManager.getFile(url.path) + ?? CEWorkspaceFile(url: URL(fileURLWithPath: url.path)) + + // Handle existing destination via the supplied confirmation closure + if fileManager.fileExists(atPath: destURL.path) { + guard confirmReplace(url.lastPathComponent) else { + continue + } + try fileManager.removeItem(at: destURL) + } + + operations.append(Operation(source: source, destination: destURL, isCopy: isCopyOperation)) + } + + return operations + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Files/FileMover.swift b/CodeEdit/WorkspaceWindow/Workspace/Files/FileMover.swift new file mode 100644 index 0000000000..e81b7948ff --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Files/FileMover.swift @@ -0,0 +1,35 @@ +// +// FileMover.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/04/26. +// + +import Foundation +import CEWorkspaceFileManager +import CodeEditCore + +/// Moves a file within a workspace, closing any open tabs for it and reopening the new location. +/// +/// Returns the resolved new file (for non-folder moves) so the caller can update its UI. +@MainActor +final class FileMover { + + func execute(file: CEWorkspaceFile, to destination: URL, in workspace: Workspace) throws -> CEWorkspaceFile? { + guard let newFile = try workspace.workspaceFileManager.move(file: file, to: destination) else { + return nil + } + + guard !newFile.isFolder else { + return newFile + } + + if !file.isFolder { + workspace.editorManager.editorLayout.closeAllTabs(of: file) + } + workspace.revealRequests.send(newFile) + workspace.editorManager.openTab(item: newFile) + + return newFile + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/Workspace.swift b/CodeEdit/WorkspaceWindow/Workspace/Workspace.swift new file mode 100644 index 0000000000..8b1caeef93 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/Workspace.swift @@ -0,0 +1,145 @@ +// +// Workspace.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 06.04.26. +// + +import CESourceControl +import AppKit +import Combine +import CEWorkspaceFileManager +import CodeEditCore +import CEEditor +import CENotifications +import CESearch +import CETerminal +import SwiftUI +import Foundation + +/// A plain model representing an open workspace (folder). +/// Constructed complete by ``WorkspaceFactory/make(url:dependencies:)`` — every +/// manager is non-optional for the workspace's lifetime. +@MainActor +final class Workspace { + let fileURL: URL + let displayName: String + + let editorManager: EditorManager + let workspaceFileManager: CEWorkspaceFileManager + let sourceControlManager: SourceControlManager + let sourceControlViewModel: SourceControlViewModel + let searchState: SearchState + let taskManager: TaskManager + let workspaceSettingsManager: CEWorkspaceSettings + let statePersistence: WorkspaceStatePersistence + let undoRegistry: UndoManagerRegistry + + // Navigator-coupled — stays until the Navigator feature is packaged + // (consumed by the ProjectNavigator AppKit cluster and by-workspace command paths). + let projectNavigatorViewModel: ProjectNavigatorViewModel + + /// Requests to reveal a file in the Project Navigator. The navigator's outline view subscribes + /// and scrolls to each file. A request is a one-shot event, not retained state — which is why + /// this is a subject rather than a `@Published` property. + let revealRequests = PassthroughSubject() + + /// The original (possibly bookmark-derived) security-scoped URL whose access is held for this + /// workspace's lifetime. Set by `WorkspaceFactory` when the URL is security-scoped (e.g. opened + /// from recents in the sandbox); released in ``tearDown()``. + var securityScopedURL: URL? + + init( + fileURL: URL, + displayName: String, + editorManager: EditorManager, + workspaceFileManager: CEWorkspaceFileManager, + sourceControlManager: SourceControlManager, + sourceControlViewModel: SourceControlViewModel, + searchState: SearchState, + taskManager: TaskManager, + workspaceSettingsManager: CEWorkspaceSettings, + statePersistence: WorkspaceStatePersistence, + undoRegistry: UndoManagerRegistry, + projectNavigatorViewModel: ProjectNavigatorViewModel, + securityScopedURL: URL? + ) { + self.fileURL = fileURL + self.displayName = displayName + self.editorManager = editorManager + self.workspaceFileManager = workspaceFileManager + self.sourceControlManager = sourceControlManager + self.sourceControlViewModel = sourceControlViewModel + self.searchState = searchState + self.taskManager = taskManager + self.workspaceSettingsManager = workspaceSettingsManager + self.statePersistence = statePersistence + self.undoRegistry = undoRegistry + self.projectNavigatorViewModel = projectNavigatorViewModel + self.securityScopedURL = securityScopedURL + } + + // MARK: - Tear Down + + /// Cleanup-only: saves restoration state and releases external resources. + /// Members are no longer nil-ed — `WorkspaceLifecycleTests` guards against leaks instead. + func tearDown() { + editorManager.saveRestorationState(statePersistence) + workspaceFileManager.cleanUp() + workspaceSettingsManager.cleanUp() + securityScopedURL?.stopAccessingSecurityScopedResource() + securityScopedURL = nil + } + + // MARK: - Unsaved Changes + + func hasUnsavedChanges() -> Bool { + let editedFiles = editorManager.editorLayout + .gatherOpenFiles() + .compactMap { editorManager.document(for: $0) } + .filter(\.isDocumentEdited) + return !editedFiles.isEmpty + } + + /// Prompts the user to save any unsaved files before closing. + /// Returns `true` if all files are clean and the workspace can close, `false` if the user cancelled. + func promptSaveUnsavedFiles() -> Bool { + let editedCodeFiles = editorManager.editorLayout + .gatherOpenFiles() + .compactMap { editorManager.document(for: $0) } + .filter(\.isDocumentEdited) + + for editedCodeFile in editedCodeFiles { + let shouldClose = UnsafeMutablePointer.allocate(capacity: 1) + shouldClose.initialize(to: true) + defer { + _ = shouldClose.move() + shouldClose.deallocate() + } + editedCodeFile.canClose( + withDelegate: self, + shouldClose: #selector(document(_:shouldClose:contextInfo:)), + contextInfo: shouldClose + ) + guard shouldClose.pointee else { + return false + } + } + + let areAllClean = editorManager.editorLayout.gatherOpenFiles() + .compactMap { editorManager.document(for: $0) } + .allSatisfy { !$0.isDocumentEdited } + return areAllClean + } + + @objc + func document( + _ document: NSDocument, + shouldClose: Bool, + contextInfo: UnsafeMutableRawPointer + ) { + let opaquePtr = OpaquePointer(contextInfo) + let mutablePointer = UnsafeMutablePointer(opaquePtr) + mutablePointer.pointee = shouldClose + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/WorkspaceFactory.swift b/CodeEdit/WorkspaceWindow/Workspace/WorkspaceFactory.swift new file mode 100644 index 0000000000..8bd4841959 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/WorkspaceFactory.swift @@ -0,0 +1,101 @@ +// +// WorkspaceFactory.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 07.04.26. +// + +import CESourceControl +import Foundation +import CEWorkspaceFileManager +import CEEditor +import CENotifications +import CESearch +import CETerminal + +/// Constructs and wires the manager/service object graph for a ``Workspace``. +/// +/// This factory encapsulates the dependency ordering and cross-wiring +/// required when opening a workspace, keeping `Workspace` itself a plain state container. +enum WorkspaceFactory { + + private static let ignoredFilesAndDirectories: Set = [".DS_Store"] + + /// Builds a fully-populated ``Workspace`` for the given folder URL. + @MainActor + static func make(url: URL, dependencies: AppDependencies) -> Workspace { + let (url, securityScopedURL) = prepareWorkspaceURL(url) + + let eventBus = dependencies.eventBus + let statePersistence = WorkspaceStatePersistence(workspaceURL: url) + let editorManager = EditorManager() + let sourceControlManager = SourceControlManager( + workspaceURL: url, + shellClient: dependencies.shellClient, + eventBus: eventBus, + settingsReader: dependencies.settingsAccessor + ) + let workspaceFileManager = CEWorkspaceFileManager( + folderUrl: url, + ignoredFilesAndFolders: ignoredFilesAndDirectories, + eventBus: eventBus + ) + let searchState = SearchState(workspaceURL: url, eventBus: eventBus) + let workspaceSettingsManager = CEWorkspaceSettings(workspaceURL: url) + let taskManager = TaskManager( + tasksConfiguration: workspaceSettingsManager, + workspaceURL: url, + eventBus: eventBus + ) + let undoRegistry = UndoManagerRegistry() + + // Observer registration + workspaceFileManager.addObserver(undoRegistry) + undoRegistry.editorManager = editorManager + + let workspace = Workspace( + fileURL: url, + displayName: url.lastPathComponent, + editorManager: editorManager, + workspaceFileManager: workspaceFileManager, + sourceControlManager: sourceControlManager, + sourceControlViewModel: SourceControlViewModel(), + searchState: searchState, + taskManager: taskManager, + workspaceSettingsManager: workspaceSettingsManager, + statePersistence: statePersistence, + undoRegistry: undoRegistry, + projectNavigatorViewModel: ProjectNavigatorViewModel(), + securityScopedURL: securityScopedURL + ) + + // State restoration + editorManager.restoreFromState( + statePersistence: statePersistence, + fileManager: workspaceFileManager, + findReplaceQuery: searchState.query + ) + + return workspace + } + + /// Claims security-scoped access and normalizes the workspace URL. + /// + /// Begins security-scoped access on the original (possibly bookmark-derived) URL so a + /// sandboxed build can read a workspace opened from recents. `startAccessingSecurityScopedResource` + /// returns `false` for non-scoped URLs (e.g. from the open panel / Powerbox), which access + /// fine without it. Released in `Workspace.tearDown`. The returned URL always ends with "/". + private static func prepareWorkspaceURL(_ url: URL) -> (url: URL, securityScopedURL: URL?) { + var securityScopedURL: URL? + if url.startAccessingSecurityScopedResource() { + securityScopedURL = url + } + + var url = url + if !url.absoluteString.hasSuffix("/") { + url = URL(filePath: url.absoluteURL.path(percentEncoded: false) + "/") + } + + return (url, securityScopedURL) + } +} diff --git a/CodeEdit/WorkspaceWindow/Workspace/WorkspaceStatePersistence.swift b/CodeEdit/WorkspaceWindow/Workspace/WorkspaceStatePersistence.swift new file mode 100644 index 0000000000..8006437052 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/Workspace/WorkspaceStatePersistence.swift @@ -0,0 +1,42 @@ +// +// WorkspaceStatePersistence.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 25.03.26. +// + +import CodeEditCore +import Foundation + +/// A standalone service for persisting workspace-specific UI state (window size, collapsed panels, etc.) +/// via UserDefaults. Extracted from Workspace to enable independent injection and testing. +final class WorkspaceStatePersistence: ObservableObject, WorkspaceStatePersisting { + private let workspaceURL: URL + + private var workspaceState: [String: Any] { + get { + let key = "workspaceState-\(workspaceURL.absoluteString)" + return UserDefaults.standard.object(forKey: key) as? [String: Any] ?? [:] + } + set { + let key = "workspaceState-\(workspaceURL.absoluteString)" + UserDefaults.standard.set(newValue, forKey: key) + } + } + + init(workspaceURL: URL) { + self.workspaceURL = workspaceURL + } + + func get(_ key: WorkspaceStateKey) -> Any? { + workspaceState[key.rawValue] + } + + func set(key: WorkspaceStateKey, value: Any?) { + if let value { + workspaceState.updateValue(value, forKey: key.rawValue) + } else { + workspaceState.removeValue(forKey: key.rawValue) + } + } +} diff --git a/CodeEdit/WorkspaceWindow/WorkspacePanel/ExtensionPanelContribution.swift b/CodeEdit/WorkspaceWindow/WorkspacePanel/ExtensionPanelContribution.swift new file mode 100644 index 0000000000..21358400dc --- /dev/null +++ b/CodeEdit/WorkspaceWindow/WorkspacePanel/ExtensionPanelContribution.swift @@ -0,0 +1,64 @@ +// +// ExtensionPanelContribution.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import CodeEditKit +import CodeEditUI +import ExtensionFoundation +import SwiftUI + +/// The workspace panel a contribution is assembled for. +enum PanelKind { + case navigator, inspector, utilityArea +} + +/// The sole ExtensionKit-aware contribution. +/// +/// One type, constructed once per discovered extension scene. Keeping ExtensionKit's vocabulary +/// here — rather than in the panels — is what lets another contribution source arrive later without +/// the panel code changing. +struct ExtensionPanelContribution: WorkspacePanelContribution { + let endpoint: AppExtensionIdentity + let data: ResolvedSidebar.SidebarStore + + /// Note the `.` separator: `bundleIdentifier + sceneID` concatenated without one can collide + /// between two extensions. Selection is not persisted, so the format change costs nothing. + var id: String { endpoint.bundleIdentifier + "." + data.sceneID } + var title: String { data.help ?? data.sceneID } + var systemImage: String { data.icon ?? "e.square" } + var content: AnyView { AnyView(ExtensionSceneView(with: endpoint, sceneID: data.sceneID)) } +} + +/// Collects every installed extension's scenes that target the given panel. +@MainActor +func extensionContributions( + for kind: PanelKind, + from extensionManager: ExtensionManager +) -> [any WorkspacePanelContribution] { + let sidebarKind: ResolvedSidebar.Kind + switch kind { + case .navigator: + sidebarKind = .navigator + case .inspector: + sidebarKind = .inspector + case .utilityArea: + // `ResolvedSidebar.Kind` in CodeEditKit declares exactly `case navigator, inspector` + // (`ResolvedSidebar.swift:14`), so an extension cannot declare a utility-area tab at all. + // Adding a kind is a change to CodeEditKit, a separate published repo, and is out of scope. + return [] + } + + return extensionManager + .extensions + .flatMap { ext in + ext.availableFeatures.compactMap { feature -> (any WorkspacePanelContribution)? in + if case .sidebarItem(let data) = feature, data.kind == sidebarKind { + return ExtensionPanelContribution(endpoint: ext.endpoint, data: data) + } + return nil + } + } +} diff --git a/CodeEdit/WorkspaceWindow/WorkspacePanel/PanelContributions.swift b/CodeEdit/WorkspaceWindow/WorkspacePanel/PanelContributions.swift new file mode 100644 index 0000000000..8b93fc54a9 --- /dev/null +++ b/CodeEdit/WorkspaceWindow/WorkspacePanel/PanelContributions.swift @@ -0,0 +1,84 @@ +// +// PanelContributions.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import CESearch +import CESourceControl +import CodeEditCore +import CodeEditUI + +/// The ids of the first-party panel tabs. +/// +/// Selecting a tab means assigning its id, so these exist to keep every call site that does so +/// compile-checked. A bare string typo is silent: guarded sites become a no-op, unguarded ones +/// select an id no contribution has and the panel renders "No Selection". Extension-provided ids +/// are dynamic and deliberately absent. +enum PanelTabID { + static let project = "project" + /// Owned by `CESourceControl.SourceControlNavigatorContribution`. + static let sourceControl = SourceControlNavigatorContribution.tabID + /// Owned by `CESearch.FindNavigatorContribution`, which vends the tab this id selects — kept + /// as one source of truth rather than duplicated as a literal. + static let search = FindNavigatorContribution.tabID + + static let file = "file" + /// Owned by `CESourceControl.GitHistoryInspectorContribution`. + static let gitHistory = GitHistoryInspectorContribution.tabID + static let internalDevelopment = "internalDevelopment" + + static let terminal = "terminal" + static let debugConsole = "debugConsole" + static let output = "output" +} + +/// The `navigator` and `activeEditorState` parameters below are deliberately **required**. A +/// default would let a forgotten injection compile and then fail silently at runtime — a no-op +/// navigator means clicking a changed file does nothing, which no gate can see. Callers with no +/// workspace (tests) pass `CodeEditCore`'s no-op types explicitly, which is a choice rather than an +/// accident. +@MainActor +func navigatorContributions( + extensionManager: ExtensionManager, + navigator: WorkspaceNavigator +) -> [any WorkspacePanelContribution] { + var items: [any WorkspacePanelContribution] = [ + ProjectNavigatorContribution(), + SourceControlNavigatorContribution(navigator: navigator), + FindNavigatorContribution() + ] + items += extensionContributions(for: .navigator, from: extensionManager) + return items +} + +@MainActor +func inspectorContributions( + extensionManager: ExtensionManager, + showInternalDevelopment: Bool, + activeEditorState: ActiveEditorState +) -> [any WorkspacePanelContribution] { + var items: [any WorkspacePanelContribution] = [ + FileInspectorContribution(), + GitHistoryInspectorContribution(activeEditorState: activeEditorState) + ] + if showInternalDevelopment { + items.append(InternalDevelopmentInspectorContribution()) + } + items += extensionContributions(for: .inspector, from: extensionManager) + return items +} + +@MainActor +func utilityAreaContributions( + extensionManager: ExtensionManager +) -> [any WorkspacePanelContribution] { + var items: [any WorkspacePanelContribution] = [ + TerminalUtilityContribution(), + DebugConsoleUtilityContribution(), + OutputUtilityContribution() + ] + items += extensionContributions(for: .utilityArea, from: extensionManager) + return items +} diff --git a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelTabBar+IconButton.swift b/CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelTabBar+IconButton.swift similarity index 81% rename from CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelTabBar+IconButton.swift rename to CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelTabBar+IconButton.swift index 1ad6b07ccc..ffbb559b89 100644 --- a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelTabBar+IconButton.swift +++ b/CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelTabBar+IconButton.swift @@ -1,24 +1,26 @@ // -// IconButton.swift +// WorkspacePanelTabBar+IconButton.swift // CodeEdit // // Created by Khan Winter on 9/3/25. // import SwiftUI +import CodeEditSettings +import CodeEditUI extension WorkspacePanelTabBar { struct IconButton: View { - let tab: Tab + let tab: any WorkspacePanelContribution let scale: Image.Scale = .medium let size: CGSize - var position: SettingsData.SidebarTabBarPosition + var position: GeneralSettings.SidebarTabBarPosition - @Binding var selection: Tab? + @Binding var selectionID: String? var symbolVariant: SymbolVariants { - if #unavailable(macOS 26), selection == tab { + if #unavailable(macOS 26), selectionID == tab.id { .fill } else { .none @@ -27,7 +29,7 @@ extension WorkspacePanelTabBar { var body: some View { Button { - selection = tab + selectionID = tab.id } label: { getSafeImage(named: tab.systemImage, accessibilityDescription: tab.title) .font(.system(size: 13)) @@ -58,12 +60,12 @@ extension WorkspacePanelTabBar { if #available(macOS 26, *) { if position == .side { .capsuleIcon( - isActive: tab == selection, + isActive: selectionID == tab.id, size: CGSize(width: 26, height: 40) ) } else { .capsuleIcon( - isActive: tab == selection, + isActive: selectionID == tab.id, height: 28 ) } @@ -74,7 +76,7 @@ extension WorkspacePanelTabBar { private var buttonStyle: IconButtonStyle { .icon( - isActive: tab == selection, + isActive: selectionID == tab.id, size: CGSize( width: position == .side ? 24 : 42, height: position == .side ? 40 : size.height diff --git a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelTabBar.swift b/CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelTabBar.swift similarity index 79% rename from CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelTabBar.swift rename to CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelTabBar.swift index d3deaa8c6d..2996a76dda 100644 --- a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelTabBar.swift +++ b/CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelTabBar.swift @@ -6,26 +6,24 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings +import CodeEditUI -protocol WorkspacePanelTab: View, Identifiable, Hashable { - var title: String { get } - var systemImage: String { get } -} - -struct WorkspacePanelTabBar: View { - @Binding var items: [Tab] - @Binding var selection: Tab? +struct WorkspacePanelTabBar: View { + @Binding var items: [any WorkspacePanelContribution] + @Binding var selectionID: String? - var position: SettingsData.SidebarTabBarPosition + var position: GeneralSettings.SidebarTabBarPosition - @State private var tabLocations: [Tab: CGRect] = [:] - @State private var tabWidth: [Tab: CGFloat] = [:] - @State private var tabOffsets: [Tab: CGFloat] = [:] + @State private var tabLocations: [String: CGRect] = [:] + @State private var tabWidth: [String: CGFloat] = [:] + @State private var tabOffsets: [String: CGFloat] = [:] /// The tab currently being dragged. /// /// It will be `nil` when there is no tab dragged currently. - @State private var draggingTab: Tab? + @State private var draggingTabID: String? /// The start location of dragging. /// @@ -50,7 +48,7 @@ struct WorkspacePanelTabBar: View { GeometryReader { proxy in iconsView(size: proxy.size) .frame(maxWidth: .infinity, maxHeight: .infinity) - .animation(.default, value: items) + .animation(.default, value: items.map(\.id)) } .clipped() .if(.tahoe) { @@ -67,7 +65,7 @@ struct WorkspacePanelTabBar: View { .if(!.tahoe) { $0.padding(.vertical, 5).frame(maxWidth: .infinity, maxHeight: .infinity) } - .animation(.default, value: items) + .animation(.default, value: items.map(\.id)) } .clipped() .if(.tahoe) { @@ -86,11 +84,11 @@ struct WorkspacePanelTabBar: View { layout { if #available(macOS 26, *) { - ForEach(Array(items.enumerated()), id: \.element) { (idx, tab) in + ForEach(Array(items.enumerated()), id: \.element.id) { (idx, tab) in tabViewTahoe(tab, next: items[safe: idx + 1], size: size) } } else { - ForEach(items) { tab in + ForEach(items, id: \.id) { tab in tabView(tab, size: size) } } @@ -107,11 +105,11 @@ struct WorkspacePanelTabBar: View { } @ViewBuilder - private func tabView(_ tab: Tab, size: CGSize) -> some View { - IconButton(tab: tab, size: size, position: position, selection: $selection) + private func tabView(_ tab: any WorkspacePanelContribution, size: CGSize) -> some View { + IconButton(tab: tab, size: size, position: position, selectionID: $selectionID) .offset( - x: (position == .top) ? (tabOffsets[tab] ?? 0) : 0, - y: (position == .side) ? (tabOffsets[tab] ?? 0) : 0 + x: (position == .top) ? (tabOffsets[tab.id] ?? 0) : 0, + y: (position == .side) ? (tabOffsets[tab.id] ?? 0) : 0 ) .background(makeTabItemGeometryReader(tab: tab)) .simultaneousGesture(makeAreaTabDragGesture(tab: tab)) @@ -119,7 +117,11 @@ struct WorkspacePanelTabBar: View { @available(macOS 26, *) @ViewBuilder - private func tabViewTahoe(_ tab: Tab, next: Tab?, size: CGSize) -> some View { + private func tabViewTahoe( + _ tab: any WorkspacePanelContribution, + next: (any WorkspacePanelContribution)?, + size: CGSize + ) -> some View { let layout = position == .top ? AnyLayout(HStackLayout(spacing: 0)) : AnyLayout(VStackLayout(spacing: 0)) @@ -130,17 +132,17 @@ struct WorkspacePanelTabBar: View { ? 5 : 2 - IconButton(tab: tab, size: size, position: position, selection: $selection) + IconButton(tab: tab, size: size, position: position, selectionID: $selectionID) .offset( - x: (position == .top) ? (tabOffsets[tab] ?? 0) : 0, - y: (position == .side) ? (tabOffsets[tab] ?? 0) : 0 + x: (position == .top) ? (tabOffsets[tab.id] ?? 0) : 0, + y: (position == .side) ? (tabOffsets[tab.id] ?? 0) : 0 ) .background(makeTabItemGeometryReader(tab: tab)) .simultaneousGesture(makeAreaTabDragGesture(tab: tab)) .overlay { // overlay to avoid layout adjustment when appearing/disappearing layout { Spacer() - if tab != items.last && selection != tab && next != selection { + if tab.id != items.last?.id && selectionID != tab.id && next?.id != selectionID { Divider().padding(paddingDirection, paddingAmount) } } @@ -151,23 +153,23 @@ struct WorkspacePanelTabBar: View { // MARK: - Drag Gesture private extension WorkspacePanelTabBar { - func makeAreaTabDragGesture(tab: Tab) -> some Gesture { + func makeAreaTabDragGesture(tab: any WorkspacePanelContribution) -> some Gesture { DragGesture(minimumDistance: 2, coordinateSpace: .global) .onChanged({ value in - if draggingTab != tab { + if draggingTabID != tab.id { initializeDragGesture(value: value, for: tab) } // Get the current cursor location let currentLocation = (position == .top) ? value.location.x : value.location.y guard let startLocation = draggingStartLocation, - let currentIndex = items.firstIndex(of: tab), - let currentTabWidth = tabWidth[tab], + let currentIndex = items.firstIndex(where: { $0.id == tab.id }), + let currentTabWidth = tabWidth[tab.id], let lastLocation = draggingLastLocation else { return } let dragDifference = currentLocation - lastLocation - tabOffsets[tab] = currentLocation - startLocation + tabOffsets[tab.id] = currentLocation - startLocation // Check for swaps between adjacent tabs // Left tab @@ -202,13 +204,13 @@ private extension WorkspacePanelTabBar { tabOffsets = [:] } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - draggingTab = nil + draggingTabID = nil } }) } - func initializeDragGesture(value: DragGesture.Value, for tab: Tab) { - draggingTab = tab + func initializeDragGesture(value: DragGesture.Value, for tab: any WorkspacePanelContribution) { + draggingTabID = tab.id let initialLocation = position == .top ? value.startLocation.x : value.startLocation.y draggingStartLocation = initialLocation draggingLastLocation = initialLocation @@ -221,7 +223,7 @@ private extension WorkspacePanelTabBar { // swiftlint:disable:next function_parameter_count func swapTab( - tab: Tab, + tab: any WorkspacePanelContribution, currentIndex: Int, currentLocation: CGFloat, dragDifference: CGFloat, @@ -247,8 +249,8 @@ private extension WorkspacePanelTabBar { // Get info about the tab to swap with let swapTab = items[swapIndex] - guard let swapTabLocation = tabLocations[swapTab], - let swapTabWidth = tabWidth[swapTab] + guard let swapTabLocation = tabLocations[swapTab.id], + let swapTabWidth = tabWidth[swapTab.id] else { return } let isWithinBounds: Bool @@ -266,7 +268,7 @@ private extension WorkspacePanelTabBar { if isWithinBounds { let changing = swapTabWidth - 1 draggingStartLocation! += direction == .previous ? -changing : changing - tabOffsets[tab]! += direction == .previous ? changing : -changing + tabOffsets[tab.id]! += direction == .previous ? changing : -changing items.swapAt(currentIndex, swapIndex) } } @@ -307,19 +309,19 @@ private extension WorkspacePanelTabBar { ) } - func makeTabItemGeometryReader(tab: Tab) -> some View { + func makeTabItemGeometryReader(tab: any WorkspacePanelContribution) -> some View { GeometryReader { geometry in Rectangle() .foregroundColor(.clear) .onAppear { - self.tabWidth[tab] = (position == .top) ? geometry.size.width : geometry.size.height - self.tabLocations[tab] = geometry.frame(in: .global) + self.tabWidth[tab.id] = (position == .top) ? geometry.size.width : geometry.size.height + self.tabLocations[tab.id] = geometry.frame(in: .global) } .onChange(of: geometry.frame(in: .global)) { _, newFrame in - self.tabLocations[tab] = newFrame + self.tabLocations[tab.id] = newFrame } .onChange(of: geometry.size.width) { _, newWidth in - self.tabWidth[tab] = newWidth + self.tabWidth[tab.id] = newWidth } } } diff --git a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelView.swift b/CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelView.swift similarity index 63% rename from CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelView.swift rename to CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelView.swift index 2123551083..bff4daa445 100644 --- a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanel/WorkspacePanelView.swift +++ b/CodeEdit/WorkspaceWindow/WorkspacePanel/WorkspacePanelView.swift @@ -6,35 +6,35 @@ // import SwiftUI +import CodeEditSettings +import CodeEditUI -struct WorkspacePanelView: View { +struct WorkspacePanelView: View { @ObservedObject var viewModel: ViewModel - @Binding var selectedTab: Tab? - @Binding var tabItems: [Tab] + @Binding var selectedTabID: String? + @Binding var tabItems: [any WorkspacePanelContribution] @Environment(\.colorScheme) private var colorScheme - var sidebarPosition: SettingsData.SidebarTabBarPosition + var sidebarPosition: GeneralSettings.SidebarTabBarPosition var darkDivider: Bool let padSideItemVertically: Bool let sideOnTrailing: Bool let sidebarPadding: () -> (Edge.Set, CGFloat) - let bottomAccessory: BottomAccessory init( viewModel: ViewModel, - selectedTab: Binding, - tabItems: Binding<[Tab]>, - sidebarPosition: SettingsData.SidebarTabBarPosition, + selectedTabID: Binding, + tabItems: Binding<[any WorkspacePanelContribution]>, + sidebarPosition: GeneralSettings.SidebarTabBarPosition, darkDivider: Bool = false, padSideItemVertically: Bool = false, sideOnTrailing: Bool = false, - sidebarPadding: @escaping () -> (Edge.Set, CGFloat) = { ([], 0) }, - @ViewBuilder bottomAccessory: () -> BottomAccessory + sidebarPadding: @escaping () -> (Edge.Set, CGFloat) = { ([], 0) } ) { self.viewModel = viewModel - self._selectedTab = selectedTab + self._selectedTabID = selectedTabID self._tabItems = tabItems self.sidebarPosition = sidebarPosition self.darkDivider = darkDivider @@ -45,41 +45,22 @@ struct WorkspacePanelView, - tabItems: Binding<[Tab]>, - sidebarPosition: SettingsData.SidebarTabBarPosition, - darkDivider: Bool = false, - padSideItemVertically: Bool = false, - sidebarPadding: @escaping () -> (Edge.Set, CGFloat) = { ([], 0) }, - sideOnTrailing: Bool = false, - ) where BottomAccessory == EmptyView { - self.viewModel = viewModel - self._selectedTab = selectedTab - self._tabItems = tabItems - self.sidebarPosition = sidebarPosition - self.darkDivider = darkDivider - self.padSideItemVertically = padSideItemVertically - if #available(macOS 26, *) { - self.sideOnTrailing = sideOnTrailing - } else { - self.sideOnTrailing = false - } - self.sidebarPadding = sidebarPadding - self.bottomAccessory = EmptyView() + private var selectedTab: (any WorkspacePanelContribution)? { + guard let selectedTabID else { return nil } + return tabItems.first { $0.id == selectedTabID } } var body: some View { VStack(spacing: 0) { if let selection = selectedTab { - selection + selection.content .safeAreaInset(edge: .bottom, spacing: 0) { + // Pre-Tahoe the accessory sits inside the tab's own content; from macOS 26 it + // spans the panel, below the tab bar. See the matching inset at the bottom. if #unavailable(macOS 26) { - bottomAccessory + selection.bottomView } } } else { @@ -103,7 +84,7 @@ struct WorkspacePanelView( - get: { sourceControlManager.pushSheetIsPresented && - !sourceControlManager.addExistingRemoteSheetIsPresented }, - set: { sourceControlManager.pushSheetIsPresented = $0 } + get: { sourceControlViewModel.pushSheetIsPresented && + !sourceControlViewModel.addExistingRemoteSheetIsPresented }, + set: { sourceControlViewModel.pushSheetIsPresented = $0 } )) { SourceControlPushView() } .sheet(isPresented: Binding( - get: { sourceControlManager.pullSheetIsPresented && - !sourceControlManager.addExistingRemoteSheetIsPresented && - !sourceControlManager.stashSheetIsPresented }, - set: { sourceControlManager.pullSheetIsPresented = $0 } + get: { sourceControlViewModel.pullSheetIsPresented && + !sourceControlViewModel.addExistingRemoteSheetIsPresented && + !sourceControlViewModel.stashSheetIsPresented }, + set: { sourceControlViewModel.pullSheetIsPresented = $0 } )) { - if sourceControlManager.addExistingRemoteSheetIsPresented == true { + if sourceControlViewModel.addExistingRemoteSheetIsPresented == true { SourceControlAddExistingRemoteView() } else { SourceControlPullView() } } - .sheet(isPresented: $sourceControlManager.fetchSheetIsPresented) { + .sheet(isPresented: $sourceControlViewModel.fetchSheetIsPresented) { SourceControlFetchView() } - .sheet(isPresented: $sourceControlManager.stashSheetIsPresented) { + .sheet(isPresented: $sourceControlViewModel.stashSheetIsPresented) { SourceControlStashView() } - .sheet(isPresented: $sourceControlManager.addExistingRemoteSheetIsPresented) { + .sheet(isPresented: $sourceControlViewModel.addExistingRemoteSheetIsPresented) { SourceControlAddExistingRemoteView() } .sheet(item: Binding( get: { - sourceControlManager.switchToBranch != nil - && sourceControlManager.stashSheetIsPresented + sourceControlViewModel.switchToBranch != nil + && sourceControlViewModel.stashSheetIsPresented ? nil - : sourceControlManager.switchToBranch + : sourceControlViewModel.switchToBranch }, - set: { sourceControlManager.switchToBranch = $0 } + set: { sourceControlViewModel.switchToBranch = $0 } )) { branch in SourceControlSwitchView(branch: branch) } - .alert(isPresented: $sourceControlManager.discardAllAlertIsPresented) { + .alert(isPresented: $sourceControlViewModel.discardAllAlertIsPresented) { Alert( title: Text("Do you want to discard all uncommitted, local changes?"), message: Text("This action cannot be undone."), @@ -61,22 +64,22 @@ struct WorkspaceSheets: View { secondaryButton: .cancel() ) } - .alert("Cannot Stage Changes", isPresented: $sourceControlManager.noChangesToStageAlertIsPresented) { + .alert("Cannot Stage Changes", isPresented: $sourceControlViewModel.noChangesToStageAlertIsPresented) { Button("OK", role: .cancel) {} } message: { Text("There are no uncommitted changes in the local repository for this project.") } - .alert("Cannot Unstage Changes", isPresented: $sourceControlManager.noChangesToUnstageAlertIsPresented) { + .alert("Cannot Unstage Changes", isPresented: $sourceControlViewModel.noChangesToUnstageAlertIsPresented) { Button("OK", role: .cancel) {} } message: { Text("There are no uncommitted changes in the local repository for this project.") } - .alert("Cannot Stash Changes", isPresented: $sourceControlManager.noChangesToStashAlertIsPresented) { + .alert("Cannot Stash Changes", isPresented: $sourceControlViewModel.noChangesToStashAlertIsPresented) { Button("OK", role: .cancel) {} } message: { Text("There are no uncommitted changes in the local repository for this project.") } - .alert("Cannot Discard Changes", isPresented: $sourceControlManager.noChangesToDiscardAlertIsPresented) { + .alert("Cannot Discard Changes", isPresented: $sourceControlViewModel.noChangesToDiscardAlertIsPresented) { Button("OK", role: .cancel) {} } message: { Text("There are no uncommitted changes in the local repository for this project.") diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceWindow/WorkspaceView.swift similarity index 86% rename from CodeEdit/WorkspaceView.swift rename to CodeEdit/WorkspaceWindow/WorkspaceView.swift index 81cd46b5f3..05f48d143e 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceWindow/WorkspaceView.swift @@ -5,7 +5,13 @@ // Created by Austin Condiff on 3/10/22. // +import CESourceControl import SwiftUI +import CodeEditSettings +import CodeEditCore +import CodeEditUI +import CEEditor +import CENotifications import UniformTypeIdentifiers struct WorkspaceView: View { @@ -23,9 +29,16 @@ struct WorkspaceView: View { @AppSettings(\.sourceControl.general.sourceControlIsEnabled) var sourceControlIsEnabled - @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + @EnvironmentObject private var sourceControlManager: SourceControlManager + @EnvironmentObject private var sourceControlViewModel: SourceControlViewModel + + @Environment(\.workspaceFileManager) + private var workspaceFileManager + + @Environment(\.workspaceStatePersistence) + private var statePersistence @StateObject private var themeModel: ThemeModel = .shared @@ -35,10 +48,10 @@ struct WorkspaceView: View { @State private var editorsHeight: CGFloat = 0 @State private var drawerHeight: CGFloat = 0 - private var keybindings: KeybindingManager = .shared + private let statusbarHeight: CGFloat = 29 var body: some View { - if workspace.workspaceFileManager != nil, let sourceControlManager = workspace.sourceControlManager { + if workspaceFileManager != nil { VStack { SplitViewReader { proxy in SplitView(axis: .vertical) { @@ -110,7 +123,7 @@ struct WorkspaceView: View { .onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { output in if let window = output.object as? NSWindow, self.window == window { - workspace.addToWorkspaceState( + statePersistence?.set( key: .workspaceWindowSize, value: NSStringFromRect(window.frame) ) @@ -119,11 +132,20 @@ struct WorkspaceView: View { } } .background(EffectView(.contentBackground)) - .background(WorkspaceSheets().environmentObject(sourceControlManager)) + .background( + WorkspaceSheets() + .environmentObject(sourceControlManager) + .environmentObject(sourceControlViewModel) + ) .onDrop(of: [.fileURL], isTargeted: nil) { providers in _ = handleDrop(providers: providers) return true } + // Outermost on purpose. An `.overlay`/`.background` closure is a *sibling* of the view it + // decorates, so it does not see an environment applied further in: injected on the split + // view alone, the utility area's terminal would miss it entirely — and `@EnvironmentObject` + // traps rather than degrading to nil, the way the retired theme environment key did. + .environmentObject(themeModel.activeTheme) .accessibilityElement(children: .contain) .accessibilityLabel("workspace area") } diff --git a/CodeEdit/World.swift b/CodeEdit/World.swift deleted file mode 100644 index 107f4dce09..0000000000 --- a/CodeEdit/World.swift +++ /dev/null @@ -1,6 +0,0 @@ -var currentWorld: World = .init() - -// Inspired by: https://vimeo.com/291588126 -struct World { - var shellClient: ShellClient = .live() -} diff --git a/CodeEditModules/Package.swift b/CodeEditModules/Package.swift new file mode 100644 index 0000000000..ee70991428 --- /dev/null +++ b/CodeEditModules/Package.swift @@ -0,0 +1,146 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CodeEditModules", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CodeEditCore", targets: ["CodeEditCore"]), + .library(name: "CodeEditUI", targets: ["CodeEditUI"]), + .library(name: "CodeEditDocument", targets: ["CodeEditDocument"]), + .library(name: "CodeEditSettings", targets: ["CodeEditSettings"]), + .library(name: "ShellClient", targets: ["ShellClient"]), + .library(name: "CEWorkspaceFileManager", targets: ["CEWorkspaceFileManager"]), + .library(name: "CEEditor", targets: ["CEEditor"]), + .library(name: "CELSP", targets: ["CELSP"]), + .library(name: "CENotifications", targets: ["CENotifications"]), + .library(name: "CESearch", targets: ["CESearch"]), + .library(name: "CESourceControl", targets: ["CESourceControl"]), + .library(name: "CETerminal", targets: ["CETerminal"]) + ], + dependencies: [ + // Pins match the app's Package.resolved to avoid a second resolved copy. + .package(url: "https://github.com/CodeEditApp/CodeEditSymbols.git", exact: "0.2.3"), + .package(url: "https://github.com/CodeEditApp/CodeEditSourceEditor", exact: "0.15.1"), + .package(url: "https://github.com/CodeEditApp/CodeEditTextView.git", exact: "0.12.1"), + .package(url: "https://github.com/CodeEditApp/CodeEditLanguages.git", exact: "0.1.20"), + .package(url: "https://github.com/ChimeHQ/TextStory", exact: "0.9.1"), + .package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.8.2"), + .package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", exact: "0.14.0"), + .package(url: "https://github.com/ChimeHQ/JSONRPC", exact: "0.9.0"), + .package(url: "https://github.com/weichsel/ZIPFoundation", exact: "0.9.19"), + .package(url: "https://github.com/apple/swift-async-algorithms.git", exact: "1.0.1"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.0"), + .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.0.0"), + .package(url: "https://github.com/thecoolwinter/SwiftTerm", branch: "codeedit") + ], + targets: [ + // MARK: - Kernel + // Rule: zero dependencies, no UI imports, platform-free. See ARCHITECTURE.md. + .target(name: "CodeEditCore"), + + // MARK: - Shared substrate + // Rule: CodeEditUI depends on CodeEditSymbols only — no local targets. + .target( + name: "CodeEditUI", + dependencies: [.product(name: "CodeEditSymbols", package: "CodeEditSymbols")], + resources: [.process("Resources")] + ), + .target(name: "CodeEditSettings", dependencies: ["CodeEditCore"]), + + // MARK: - Editor substrate + // CodeFileDocument + editor-framework bridging; consumed only by CEEditor and CELSP. + .target( + name: "CodeEditDocument", + dependencies: [ + "CodeEditCore", + .product(name: "CodeEditSourceEditor", package: "CodeEditSourceEditor"), + .product(name: "CodeEditTextView", package: "CodeEditTextView"), + .product(name: "CodeEditLanguages", package: "CodeEditLanguages"), + .product(name: "TextStory", package: "TextStory") + ] + ), + + // MARK: - App-linked services + // Zero package-internal consumers; the app target links these directly. + .target(name: "ShellClient", dependencies: ["CodeEditCore"]), + .target(name: "CEWorkspaceFileManager", dependencies: ["CodeEditCore"]), + + // MARK: - Features + // Norm: prefer features to be leaves. Declare any feature→feature edge here. + .target( + name: "CEEditor", + dependencies: [ + "CodeEditCore", + "CodeEditUI", + "CodeEditDocument", + "CodeEditSettings", + .product(name: "CodeEditSourceEditor", package: "CodeEditSourceEditor"), + .product(name: "CodeEditTextView", package: "CodeEditTextView"), + .product(name: "CodeEditLanguages", package: "CodeEditLanguages"), + .product(name: "CodeEditSymbols", package: "CodeEditSymbols"), + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "DequeModule", package: "swift-collections") + ], + // The ONLY target permitted to opt out of Swift 6. See ARCHITECTURE.md. + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .target( + name: "CELSP", + dependencies: [ + "CodeEditCore", + "CodeEditDocument", + "CodeEditSettings", + .product(name: "CodeEditSourceEditor", package: "CodeEditSourceEditor"), + .product(name: "CodeEditTextView", package: "CodeEditTextView"), + .product(name: "CodeEditLanguages", package: "CodeEditLanguages"), + .product(name: "LanguageClient", package: "LanguageClient"), + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + ] + ), + .target(name: "CENotifications", dependencies: ["CodeEditCore", "CodeEditUI"]), + .target(name: "CESearch", dependencies: ["CodeEditCore", "CodeEditUI", "CodeEditSettings"]), + .target( + name: "CESourceControl", + dependencies: [ + "CodeEditCore", + "CodeEditSettings", + "CodeEditUI", + .product(name: "CodeEditSymbols", package: "CodeEditSymbols") + ] + ), + .target( + name: "CETerminal", + dependencies: [ + "CodeEditCore", + "CodeEditSettings", + "CodeEditUI", + .product(name: "SwiftTerm", package: "SwiftTerm") + ] + ), + + // MARK: - Tests + .testTarget(name: "CodeEditCoreTests", dependencies: ["CodeEditCore"]), + .testTarget( + name: "CodeEditSettingsTests", + dependencies: ["CodeEditSettings", "CELSP", "CESourceControl", "CETerminal"], + resources: [.copy("Fixtures")] + ), + .testTarget(name: "CodeEditUIUnitTests", dependencies: ["CodeEditUI"]), + .testTarget(name: "CESearchTests", dependencies: ["CESearch", "CodeEditCore"]), + .testTarget( + name: "CELSPTests", + dependencies: [ + "CELSP", + .product(name: "CodeEditSourceEditor", package: "CodeEditSourceEditor"), + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol") + ] + ), + .testTarget(name: "CESourceControlTests", dependencies: ["CESourceControl"]) + ] +) diff --git a/CodeEditModules/Sources/CEEditor/Adapters/AppActiveCursorState.swift b/CodeEditModules/Sources/CEEditor/Adapters/AppActiveCursorState.swift new file mode 100644 index 0000000000..56dea480ae --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Adapters/AppActiveCursorState.swift @@ -0,0 +1,63 @@ +// +// AppActiveCursorState.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Combine +import CodeEditCore +import CodeEditSourceEditor + +/// App-side `ActiveCursorState` over a window's `EditorManager`. Publishes the +/// active editor's cursor positions across both active-editor switches and +/// within-editor tab changes, and forwards `linesInRange(_:)` to the live +/// `EditorInstance.rangeTranslator`. +public final class AppActiveCursorState: ActiveCursorState { + private let subject: CurrentValueSubject<[EditorCursorPosition], Never> + private weak var currentTab: EditorInstance? + private var editorCancellable: AnyCancellable? + private var cursorCancellable: AnyCancellable? + + @MainActor + public init(editorManager: EditorManager) { + let initialTab = editorManager.activeEditor.selectedTab + currentTab = initialTab + subject = CurrentValueSubject(Self.map(initialTab?.cursorPositions ?? [])) + + editorCancellable = editorManager.$activeEditor + .flatMap { $0.$selectedTab } + .sink { [weak self] tab in + self?.bind(to: tab) + } + } + + @MainActor + private func bind(to tab: EditorInstance?) { + currentTab = tab + guard let tab else { + cursorCancellable = nil + subject.send([]) + return + } + cursorCancellable = tab.$cursorPositions + .sink { [weak subject] positions in + subject?.send(Self.map(positions)) + } + } + + private static func map(_ positions: [CursorPosition]) -> [EditorCursorPosition] { + positions.map { EditorCursorPosition(line: $0.start.line, column: $0.start.column, range: $0.range) } + } + + public var cursorPositions: [EditorCursorPosition] { subject.value } + + public var cursorPositionsPublisher: AnyPublisher<[EditorCursorPosition], Never> { + subject.eraseToAnyPublisher() + } + + public func linesInRange(_ range: NSRange) -> Int { + currentTab?.rangeTranslator.linesInRange(range) ?? 0 + } +} diff --git a/CodeEditModules/Sources/CEEditor/Adapters/AppActiveEditorState.swift b/CodeEditModules/Sources/CEEditor/Adapters/AppActiveEditorState.swift new file mode 100644 index 0000000000..1b973f6f64 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Adapters/AppActiveEditorState.swift @@ -0,0 +1,29 @@ +// +// AppActiveEditorState.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Combine +import CodeEditCore + +/// App-side `ActiveEditorState` over a window's `EditorManager`. Emits the active editor's +/// selected file across both active-editor switches and within-editor tab changes. +public final class AppActiveEditorState: ActiveEditorState { + private let subject: CurrentValueSubject + private var cancellable: AnyCancellable? + + @MainActor + public init(editorManager: EditorManager) { + subject = CurrentValueSubject(editorManager.activeEditor.selectedTab?.file) + cancellable = editorManager.$activeEditor + .flatMap { $0.$selectedTab } + .map { $0?.file } + .sink { [weak subject] file in subject?.send(file) } + } + + public var selectedFile: CEWorkspaceFile? { subject.value } + public var selectedFilePublisher: AnyPublisher { subject.eraseToAnyPublisher() } +} diff --git a/CodeEditModules/Sources/CEEditor/Adapters/AppFileEditorOverrides.swift b/CodeEditModules/Sources/CEEditor/Adapters/AppFileEditorOverrides.swift new file mode 100644 index 0000000000..53105fc864 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Adapters/AppFileEditorOverrides.swift @@ -0,0 +1,56 @@ +// +// AppFileEditorOverrides.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import CodeEditCore +import CodeEditLanguages +import CodeEditDocument + +/// App-side `FileEditorOverrides` over a window's `EditorManager`. Reads and writes +/// the per-file overrides on the file's open `CodeFileDocument`, translating the +/// language override to/from `CodeLanguage.id.rawValue`. +public final class AppFileEditorOverrides: FileEditorOverrides { + private let editorManager: EditorManager + + @MainActor + public init(editorManager: EditorManager) { + self.editorManager = editorManager + } + + @MainActor + public func overrides(for file: CEWorkspaceFile) -> FileEditorOverrideValues { + let document = editorManager.document(for: file) + return FileEditorOverrideValues( + indentOption: document?.indentOption, + defaultTabWidth: document?.defaultTabWidth, + wrapLines: document?.wrapLines, + languageId: document?.language?.id.rawValue + ) + } + + @MainActor + public func setIndentOption(_ value: IndentOption?, for file: CEWorkspaceFile) { + editorManager.document(for: file)?.indentOption = value + } + + @MainActor + public func setDefaultTabWidth(_ value: Int?, for file: CEWorkspaceFile) { + editorManager.document(for: file)?.defaultTabWidth = value + } + + @MainActor + public func setWrapLines(_ value: Bool?, for file: CEWorkspaceFile) { + editorManager.document(for: file)?.wrapLines = value + } + + @MainActor + public func setLanguageId(_ value: String?, for file: CEWorkspaceFile) { + editorManager.document(for: file)?.language = value.flatMap { id in + CodeLanguage.allLanguages.first { $0.id.rawValue == id } + } + } +} diff --git a/CodeEdit/Features/Editor/Models/Environment+ActiveEditor.swift b/CodeEditModules/Sources/CEEditor/Adapters/Environment+ActiveEditor.swift similarity index 53% rename from CodeEdit/Features/Editor/Models/Environment+ActiveEditor.swift rename to CodeEditModules/Sources/CEEditor/Adapters/Environment+ActiveEditor.swift index 6e400e3e90..1c55c54ce2 100644 --- a/CodeEdit/Features/Editor/Models/Environment+ActiveEditor.swift +++ b/CodeEditModules/Sources/CEEditor/Adapters/Environment+ActiveEditor.swift @@ -7,11 +7,12 @@ import SwiftUI -struct ActiveEditorEnvironmentKey: EnvironmentKey { - static var defaultValue = false +public struct ActiveEditorEnvironmentKey: EnvironmentKey { + nonisolated(unsafe) public static var defaultValue = false } -extension EnvironmentValues { +public extension EnvironmentValues { + /// Whether the editor this view belongs to is the focused editor in the window. var isActiveEditor: Bool { get { self[ActiveEditorEnvironmentKey.self] } set { self[ActiveEditorEnvironmentKey.self] = newValue } diff --git a/CodeEditModules/Sources/CEEditor/Adapters/Environment+WorkspaceFileProvider.swift b/CodeEditModules/Sources/CEEditor/Adapters/Environment+WorkspaceFileProvider.swift new file mode 100644 index 0000000000..eb20879c0f --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Adapters/Environment+WorkspaceFileProvider.swift @@ -0,0 +1,22 @@ +// +// Environment+WorkspaceFileProvider.swift +// CEEditor +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +import SwiftUI +import CodeEditCore + +private struct WorkspaceFileProviderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: (any WorkspaceFileProviding)? = nil +} + +extension EnvironmentValues { + /// Read-mostly access to the workspace file tree (see `WorkspaceFileProviding`). + /// Optional: nil in previews and tests; injected by the app shell. + public var workspaceFileProvider: (any WorkspaceFileProviding)? { + get { self[WorkspaceFileProviderKey.self] } + set { self[WorkspaceFileProviderKey.self] = newValue } + } +} diff --git a/CodeEditModules/Sources/CEEditor/Adapters/Environment+WorkspaceNavigator.swift b/CodeEditModules/Sources/CEEditor/Adapters/Environment+WorkspaceNavigator.swift new file mode 100644 index 0000000000..3cf2410f7d --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Adapters/Environment+WorkspaceNavigator.swift @@ -0,0 +1,22 @@ +// +// Environment+WorkspaceNavigator.swift +// Editor +// +// Created by Matthijs Eikelenboom on 10/07/2026. +// + +import SwiftUI +import CodeEditCore + +private struct WorkspaceNavigatorKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: WorkspaceNavigator = NoOpWorkspaceNavigator() +} + +extension EnvironmentValues { + /// The command interface for opening, revealing, and closing files in the owning + /// workspace. No-op by default (previews, tests); injected by the app shell. + public var workspaceNavigator: WorkspaceNavigator { + get { self[WorkspaceNavigatorKey.self] } + set { self[WorkspaceNavigatorKey.self] = newValue } + } +} diff --git a/CodeEditModules/Sources/CEEditor/Documents/DocumentRegistry.swift b/CodeEditModules/Sources/CEEditor/Documents/DocumentRegistry.swift new file mode 100644 index 0000000000..47b83dd7bf --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Documents/DocumentRegistry.swift @@ -0,0 +1,67 @@ +// +// DocumentRegistry.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import Combine +import CodeEditDocument +import Foundation +import CodeEditCore + +/// Owns the workspace's file→document association, keyed by ``CEWorkspaceFile/id``. +/// +/// Storage is intentionally **weak** and change notification uses a `PassthroughSubject`, +/// mirroring the reference that previously lived (type-erased) on `CEWorkspaceFile`. The +/// strong owner of an open document remains the editor view tree; this registry only holds +/// the back-reference that non-view consumers read. +/// +/// Only ever accessed on the main thread, like the rest of the editor state. It is intentionally +/// **not** `@MainActor`: its owner (`EditorManager`) and callers are not yet actor-isolated, and +/// marking only this type does not compile in the app's current Swift 5 mode. It should become +/// `@MainActor` together with `EditorManager` during the eventual app-wide Swift 6 migration. +public final class DocumentRegistry { + private final class Box { + weak var document: CodeFileDocument? + let subject = PassthroughSubject() + } + + private var boxes: [String: Box] = [:] + + private func box(for id: String) -> Box { + if let existing = boxes[id] { return existing } + let created = Box() + boxes[id] = created + return created + } + + /// The open document for the file, or `nil` if none is loaded. + public func document(for file: CEWorkspaceFile) -> CodeFileDocument? { + boxes[file.id]?.document + } + + /// Associates (or clears, with `nil`) a document for the file and notifies subscribers. + public func setDocument(_ document: CodeFileDocument?, for file: CEWorkspaceFile) { + let box = box(for: file.id) + box.document = document + box.subject.send(document) + } + + /// Loads a new `CodeFileDocument` for the file from disk, registers it, and returns it. + @discardableResult + public func loadDocument(for file: CEWorkspaceFile) throws -> CodeFileDocument { + let document = try CodeFileDocument( + contentsOf: file.resolvedURL, + ofType: file.contentType?.identifier ?? "" + ) + setDocument(document, for: file) + return document + } + + /// Emits whenever the document association for the file changes. Like the original, + /// this does not replay the current value on subscription. + public func documentPublisher(for file: CEWorkspaceFile) -> AnyPublisher { + box(for: file.id).subject.eraseToAnyPublisher() + } +} diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEditModules/Sources/CEEditor/Documents/UndoManagerRegistry.swift similarity index 72% rename from CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift rename to CodeEditModules/Sources/CEEditor/Documents/UndoManagerRegistry.swift index a665c8991b..2301274d4e 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift +++ b/CodeEditModules/Sources/CEEditor/Documents/UndoManagerRegistry.swift @@ -1,11 +1,13 @@ // -// UndoManagerRegistration.swift +// UndoManagerRegistry.swift // CodeEdit // // Created by Khan Winter on 6/27/25. // import SwiftUI +import CodeEditDocument +import CodeEditCore import CodeEditTextView /// Very simple class for registering undo manager for files for a project session. This does not do any saving, it @@ -15,22 +17,25 @@ import CodeEditTextView /// - `CEWorkspaceFile` can be refreshed and reloaded at any point. /// - `CodeFileDocument` is released once there are no editors viewing it. /// Undo stacks need to be retained for the duration of a workspace session, enduring editor closes.. -final class UndoManagerRegistration: ObservableObject { +public final class UndoManagerRegistry: ObservableObject { private var managerMap: [String: CEUndoManager] = [:] - init() { } + /// Used to check whether a file still has an open document. Wired by `WorkspaceFactory`. + public weak var editorManager: EditorManager? + + public init() { } /// Find or create a new undo manager. /// - Parameter file: The file to create for. /// - Returns: The undo manager for the given file. - func manager(forFile file: CEWorkspaceFile) -> CEUndoManager { + public func manager(forFile file: CEWorkspaceFile) -> CEUndoManager { manager(forFile: file.url) } /// Find or create a new undo manager. /// - Parameter path: The path of the file to create for. /// - Returns: The undo manager for the given file. - func manager(forFile path: URL) -> CEUndoManager { + public func manager(forFile path: URL) -> CEUndoManager { if let manager = managerMap[path.absolutePath] { return manager } else { @@ -43,20 +48,20 @@ final class UndoManagerRegistration: ObservableObject { /// Find or create a new undo manager. /// - Parameter path: The path of the file to create for. /// - Returns: The undo manager for the given file. - func managerIfExists(forFile path: URL) -> CEUndoManager? { + public func managerIfExists(forFile path: URL) -> CEUndoManager? { managerMap[path.absolutePath] } } -extension UndoManagerRegistration: CEWorkspaceFileManagerObserver { +extension UndoManagerRegistry: WorkspaceFileObserver { /// Managers need to be cleared when the following is true: /// - The file is not open in any editors /// - The file is updated externally /// /// To handle this? /// - When we receive a file update, if the file is not open in any editors we clear the undo stack - func fileManagerUpdated(updatedItems: Set) { - for file in updatedItems where file.fileDocument == nil { + public func fileManagerUpdated(updatedItems: Set) { + for file in updatedItems where editorManager?.document(for: file) == nil { managerMap.removeValue(forKey: file.url.absolutePath) } } diff --git a/CodeEdit/Features/Editor/Models/Editor/Editor+History.swift b/CodeEditModules/Sources/CEEditor/Editor/Editor+History.swift similarity index 89% rename from CodeEdit/Features/Editor/Models/Editor/Editor+History.swift rename to CodeEditModules/Sources/CEEditor/Editor/Editor+History.swift index 8dfcf228d5..b1b0debcaf 100644 --- a/CodeEdit/Features/Editor/Models/Editor/Editor+History.swift +++ b/CodeEditModules/Sources/CEEditor/Editor/Editor+History.swift @@ -11,7 +11,7 @@ import Foundation extension Editor { /// Add the tab to the history list. /// - Parameter tab: The tab to add to the history. - func addToHistory(_ tab: Tab) { + public func addToHistory(_ tab: Tab) { if history.first != tab.file { history.prepend(tab.file) } @@ -19,21 +19,21 @@ extension Editor { /// Clear any tabs in the "future" on the history list. Resets the history offset and removes any tabs that were /// available to navigate forwards to. - func clearFuture() { + public func clearFuture() { guard historyOffset > 0 else { return } // nothing to clear, avoid an out of bounds error history.removeFirst(historyOffset) historyOffset = 0 } /// Move backwards in the history list by one place. - func goBackInHistory() { + public func goBackInHistory() { if canGoBackInHistory { historyOffset += 1 } } /// Move forwards in the history list by one place. - func goForwardInHistory() { + public func goForwardInHistory() { if canGoForwardInHistory { historyOffset -= 1 } @@ -41,13 +41,13 @@ extension Editor { // TODO: move to @Observable so this works better /// Warning: NOT published! - var canGoBackInHistory: Bool { + public var canGoBackInHistory: Bool { historyOffset != history.count - 1 && !history.isEmpty } // TODO: move to @Observable so this works better /// Warning: NOT published! - var canGoForwardInHistory: Bool { + public var canGoForwardInHistory: Bool { historyOffset != 0 } diff --git a/CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift b/CodeEditModules/Sources/CEEditor/Editor/Editor+TabSwitch.swift similarity index 75% rename from CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift rename to CodeEditModules/Sources/CEEditor/Editor/Editor+TabSwitch.swift index 62e94a3150..ec998aa7df 100644 --- a/CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift +++ b/CodeEditModules/Sources/CEEditor/Editor/Editor+TabSwitch.swift @@ -1,5 +1,5 @@ // -// EditorTabSwitchExtension.swift +// Editor+TabSwitch.swift // CodeEdit // // Created by Roscoe Rubin-Rottenberg on 4/22/24. @@ -8,7 +8,8 @@ import Foundation extension Editor { - func selectNextTab() { + /// Selects the tab after the current one, wrapping around to the first tab when at the end. + public func selectNextTab() { guard let currentTab = selectedTab, let currentIndex = tabs.firstIndex(of: currentTab) else { return } let nextIndex = tabs.index(after: currentIndex) if nextIndex < tabs.endIndex { @@ -19,7 +20,8 @@ extension Editor { } } - func selectPreviousTab() { + /// Selects the tab before the current one, wrapping around to the last tab when at the beginning. + public func selectPreviousTab() { guard let currentTab = selectedTab, let currentIndex = tabs.firstIndex(of: currentTab) else { return } let previousIndex = tabs.index(before: currentIndex) if previousIndex >= tabs.startIndex { diff --git a/CodeEdit/Features/Editor/Models/Editor/Editor.swift b/CodeEditModules/Sources/CEEditor/Editor/Editor.swift similarity index 77% rename from CodeEdit/Features/Editor/Models/Editor/Editor.swift rename to CodeEditModules/Sources/CEEditor/Editor/Editor.swift index 782b956b71..02fa6604b1 100644 --- a/CodeEdit/Features/Editor/Models/Editor/Editor.swift +++ b/CodeEditModules/Sources/CEEditor/Editor/Editor.swift @@ -6,20 +6,21 @@ // import Foundation +import CodeEditCore import OrderedCollections import DequeModule import AppKit import OSLog -final class Editor: ObservableObject, Identifiable { - enum EditorError: Error { +public final class Editor: ObservableObject, Identifiable { + public enum EditorError: Error { case noWorkspaceAttached } - typealias Tab = EditorInstance + public typealias Tab = EditorInstance /// Set of open tabs. - @Published var tabs: OrderedSet = [] { + @Published public var tabs: OrderedSet = [] { didSet { let change = tabs.symmetricDifference(oldValue) @@ -42,7 +43,7 @@ final class Editor: ObservableObject, Identifiable { /// The current offset in the history list. /// When set, updates the ``selectedTab`` to the tab indicated by the offset. /// See the ``historyOffsetDidChange()`` method for more details. - @Published var historyOffset: Int = 0 { + @Published public var historyOffset: Int = 0 { didSet { historyOffsetDidChange() } @@ -50,74 +51,80 @@ final class Editor: ObservableObject, Identifiable { /// Maintains the list of tabs that have been switched to. /// - Warning: Use the ``addToHistory(_:)`` or ``clearFuture()`` methods to modify this. Do not modify directly. - @Published var history: Deque = [] + @Published public var history: Deque = [] /// Currently selected tab. - @Published private(set) var selectedTab: Tab? + @Published public private(set) var selectedTab: Tab? - @Published var temporaryTab: Tab? + @Published public var temporaryTab: Tab? - var id = UUID() + public var id = UUID() - weak var parent: SplitViewData? - weak var workspace: WorkspaceDocument? + public weak var parent: SplitViewData? + public weak var findReplaceQuery: FindReplaceQuery? + public weak var editorManager: EditorManager? + + /// Whether this editor is attached to a workspace. Used to guard file loading operations. + public var isAttachedToWorkspace: Bool = false private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "Editor") - init() { + public init() { self.tabs = [] self.temporaryTab = nil self.parent = nil - self.workspace = nil + self.findReplaceQuery = nil } - init( + public init( files: OrderedSet = [], selectedTab: Tab? = nil, temporaryTab: Tab? = nil, parent: SplitViewData? = nil, - workspace: WorkspaceDocument? = nil + findReplaceQuery: FindReplaceQuery? = nil ) { self.parent = parent - self.workspace = workspace + self.findReplaceQuery = findReplaceQuery // If we open the files without a valid workspace, we risk creating a file we lose track of but stays in memory - if workspace != nil { + if isAttachedToWorkspace { files.forEach { openTab(file: $0) } } else { - self.tabs = OrderedSet(files.map { EditorInstance(workspace: workspace, file: $0) }) + self.tabs = OrderedSet(files.map { EditorInstance(findReplaceQuery: findReplaceQuery, file: $0) }) } - self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!)) + self.selectedTab = selectedTab ?? ( + files.isEmpty ? nil : Tab(findReplaceQuery: findReplaceQuery, file: files.first!) + ) self.temporaryTab = temporaryTab } - init( + public init( files: OrderedSet = [], selectedTab: Tab? = nil, temporaryTab: Tab? = nil, parent: SplitViewData? = nil, - workspace: WorkspaceDocument? = nil + findReplaceQuery: FindReplaceQuery? = nil ) { self.tabs = [] self.parent = parent - self.workspace = workspace + self.findReplaceQuery = findReplaceQuery files.forEach { openTab(file: $0.file) } self.selectedTab = selectedTab ?? tabs.first self.temporaryTab = temporaryTab } /// Closes the editor. - func close() { + public func close() { parent?.closeEditor(with: id) } /// Gets the editor layout. - func getEditorLayout() -> EditorLayout? { + public func getEditorLayout() -> EditorLayout? { return parent?.getEditorLayout(with: id) } /// Set the selected tab. Loads the file's contents if it hasn't already been opened. /// - Parameter file: The file to set as the selected tab. - func setSelectedTab(_ file: CEWorkspaceFile?) { + public func setSelectedTab(_ file: CEWorkspaceFile?) { guard let file else { selectedTab = nil return @@ -126,7 +133,7 @@ final class Editor: ObservableObject, Identifiable { return } self.selectedTab = tab - if tab.file.fileDocument == nil { + if editorManager?.document(for: tab.file) == nil { do { // Ignore this error for simpler API usage. try openFile(item: tab) } catch { @@ -142,7 +149,7 @@ final class Editor: ObservableObject, Identifiable { /// - fromHistory: If `true`, does not clear tabs ahead of the ``historyOffset`` /// Used when opening tabs from the history queue where tabs ahead of the ``historyOffset`` should /// not be removed. - func closeTab(file: CEWorkspaceFile, fromHistory: Bool = false) { + public func closeTab(file: CEWorkspaceFile, fromHistory: Bool = false) { guard canCloseTab(file: file) else { return } if temporaryTab?.file == file { @@ -152,23 +159,23 @@ final class Editor: ObservableObject, Identifiable { clearFuture() } if file != selectedTab?.file { - addToHistory(EditorInstance(workspace: workspace, file: file)) + addToHistory(EditorInstance(findReplaceQuery: findReplaceQuery, file: file)) } removeTab(file) if let selectedTab { addToHistory(selectedTab) } // Reset change count to 0 - file.fileDocument?.updateChangeCount(.changeCleared) - if let codeFile = file.fileDocument { + editorManager?.document(for: file)?.updateChangeCount(.changeCleared) + if let codeFile = editorManager?.document(for: file) { codeFile.close() } // remove file from memory - file.fileDocument = nil + editorManager?.setDocument(nil, for: file) } /// Closes the currently opened tab in the tab group. - func closeSelectedTab() { + public func closeSelectedTab() { guard let file = selectedTab?.file else { return } @@ -181,8 +188,8 @@ final class Editor: ObservableObject, Identifiable { /// - Parameters: /// - file: the file to open. /// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab. - func openTab(file: CEWorkspaceFile, asTemporary: Bool) { - let item = EditorInstance(workspace: workspace, file: file) + public func openTab(file: CEWorkspaceFile, asTemporary: Bool) { + let item = EditorInstance(findReplaceQuery: findReplaceQuery, file: file) // Item is already opened in a tab. guard !tabs.contains(item) || !asTemporary else { selectedTab = item @@ -239,8 +246,8 @@ final class Editor: ObservableObject, Identifiable { /// - file: The tab to open. /// - index: Index where the tab needs to be added. If nil, it is added to the back. /// - fromHistory: Indicates whether the tab has been opened from going back in history. - func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) { - let item = Tab(workspace: workspace, file: file) + public func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) { + let item = Tab(findReplaceQuery: findReplaceQuery, file: file) if let index { tabs.insert(item, at: index) } else { @@ -265,22 +272,22 @@ final class Editor: ObservableObject, Identifiable { private func openFile(item: Tab) throws { // If this isn't attached to a workspace, loading a new NSDocument will cause a loose document we can't close - guard item.file.fileDocument == nil else { + guard editorManager?.document(for: item.file) == nil else { return } - guard workspace != nil else { + guard isAttachedToWorkspace else { throw EditorError.noWorkspaceAttached } - try item.file.loadCodeFile() + try editorManager?.loadDocument(for: item.file) } /// Check if tab can be closed /// /// If document edited it will show dialog where user can save document before closing or cancel. private func canCloseTab(file: CEWorkspaceFile) -> Bool { - guard let codeFile = file.fileDocument else { return true } + guard let codeFile = editorManager?.document(for: file) else { return true } if codeFile.isDocumentEdited { let shouldClose = UnsafeMutablePointer.allocate(capacity: 1) @@ -322,7 +329,7 @@ final class Editor: ObservableObject, Identifiable { /// Remove the given file from tabs. /// - Parameter file: The file to remove. - func removeTab(_ file: CEWorkspaceFile) { + public func removeTab(_ file: CEWorkspaceFile) { tabs.removeAll(where: { tab in tab.file == file }) if temporaryTab?.file == file { temporaryTab = nil @@ -331,11 +338,11 @@ final class Editor: ObservableObject, Identifiable { } extension Editor: Equatable, Hashable { - static func == (lhs: Editor, rhs: Editor) -> Bool { + public static func == (lhs: Editor, rhs: Editor) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(id) } } diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEditModules/Sources/CEEditor/Editor/EditorInstance.swift similarity index 65% rename from CodeEdit/Features/Editor/Models/EditorInstance.swift rename to CodeEditModules/Sources/CEEditor/Editor/EditorInstance.swift index fd11333bb7..ae094caa4b 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEditModules/Sources/CEEditor/Editor/EditorInstance.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditCore import AppKit import Combine import CodeEditTextView @@ -13,34 +14,34 @@ import CodeEditSourceEditor /// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish /// the user's current location in a file. -class EditorInstance: ObservableObject, Hashable { +public class EditorInstance: ObservableObject, Hashable { /// The file presented in this editor instance. - let file: CEWorkspaceFile + public let file: CEWorkspaceFile /// A publisher for the user's current location in a file. - @Published var cursorPositions: [CursorPosition] - @Published var scrollPosition: CGPoint? + @Published public var cursorPositions: [CursorPosition] + @Published public var scrollPosition: CGPoint? - @Published var findText: String? - var findTextSubject: PassthroughSubject + @Published public var findText: String? + public var findTextSubject: PassthroughSubject - @Published var replaceText: String? - var replaceTextSubject: PassthroughSubject + @Published public var replaceText: String? + public var replaceTextSubject: PassthroughSubject - var rangeTranslator: RangeTranslator = RangeTranslator() + public var rangeTranslator: RangeTranslator = RangeTranslator() private var cancellables: Set = [] // MARK: - Init - init(workspace: WorkspaceDocument?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) { + public init(findReplaceQuery: FindReplaceQuery?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) { self.file = file let url = file.url let editorState = EditorStateRestoration.shared?.restorationState(for: url) - findText = workspace?.searchState?.searchQuery + findText = findReplaceQuery?.searchQuery findTextSubject = PassthroughSubject() - replaceText = workspace?.searchState?.replaceText + replaceText = findReplaceQuery?.replaceText replaceTextSubject = PassthroughSubject() self.cursorPositions = ( @@ -64,14 +65,14 @@ class EditorInstance: ObservableObject, Hashable { } .store(in: &cancellables) - listenToFindText(workspace: workspace) - listenToReplaceText(workspace: workspace) + listenToFindText(findReplaceQuery: findReplaceQuery) + listenToReplaceText(findReplaceQuery: findReplaceQuery) } // MARK: - Find/Replace Listeners - func listenToFindText(workspace: WorkspaceDocument?) { - workspace?.searchState?.$searchQuery + func listenToFindText(findReplaceQuery: FindReplaceQuery?) { + findReplaceQuery?.$searchQuery .receive(on: RunLoop.main) .sink { [weak self] newQuery in if self?.findText != newQuery { @@ -81,17 +82,17 @@ class EditorInstance: ObservableObject, Hashable { .store(in: &cancellables) findTextSubject .receive(on: RunLoop.main) - .sink { [weak workspace, weak self] newFindText in - if let newFindText, workspace?.searchState?.searchQuery != newFindText { - workspace?.searchState?.searchQuery = newFindText + .sink { [weak findReplaceQuery, weak self] newFindText in + if let newFindText, findReplaceQuery?.searchQuery != newFindText { + findReplaceQuery?.searchQuery = newFindText } - self?.findText = workspace?.searchState?.searchQuery + self?.findText = findReplaceQuery?.searchQuery } .store(in: &cancellables) } - func listenToReplaceText(workspace: WorkspaceDocument?) { - workspace?.searchState?.$replaceText + func listenToReplaceText(findReplaceQuery: FindReplaceQuery?) { + findReplaceQuery?.$replaceText .receive(on: RunLoop.main) .sink { [weak self] newText in if self?.replaceText != newText { @@ -101,44 +102,44 @@ class EditorInstance: ObservableObject, Hashable { .store(in: &cancellables) replaceTextSubject .receive(on: RunLoop.main) - .sink { [weak workspace, weak self] newReplaceText in - if let newReplaceText, workspace?.searchState?.replaceText != newReplaceText { - workspace?.searchState?.replaceText = newReplaceText + .sink { [weak findReplaceQuery, weak self] newReplaceText in + if let newReplaceText, findReplaceQuery?.replaceText != newReplaceText { + findReplaceQuery?.replaceText = newReplaceText } - self?.replaceText = workspace?.searchState?.replaceText + self?.replaceText = findReplaceQuery?.replaceText } .store(in: &cancellables) } // MARK: - Hashable, Equatable - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(file) } - static func == (lhs: EditorInstance, rhs: EditorInstance) -> Bool { + public static func == (lhs: EditorInstance, rhs: EditorInstance) -> Bool { lhs.file == rhs.file } // MARK: - RangeTranslator /// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range. - class RangeTranslator: TextViewCoordinator { + public class RangeTranslator: TextViewCoordinator { private weak var textViewController: TextViewController? init() { } - func prepareCoordinator(controller: TextViewController) { + public func prepareCoordinator(controller: TextViewController) { self.textViewController = controller } - func controllerDidAppear(controller: TextViewController) { + public func controllerDidAppear(controller: TextViewController) { if controller.isEditable && controller.isSelectable { controller.view.window?.makeFirstResponder(controller.textView) } } - func destroy() { + public func destroy() { self.textViewController = nil } @@ -146,7 +147,7 @@ class EditorInstance: ObservableObject, Hashable { /// - Parameter range: The range to use. /// - Returns: The number of lines contained by the given range. Or `0` if the text view could not be found, /// or lines could not be found for the given range. - func linesInRange(_ range: NSRange) -> Int { + public func linesInRange(_ range: NSRange) -> Int { guard let controller = textViewController, let scrollView = controller.view as? NSScrollView, let textView = scrollView.documentView as? TextView, @@ -158,12 +159,12 @@ class EditorInstance: ObservableObject, Hashable { return (endTextLine.index - startTextLine.index) + 1 } - func moveLinesUp() { + public func moveLinesUp() { guard let controller = textViewController else { return } controller.moveLinesUp() } - func moveLinesDown() { + public func moveLinesDown() { guard let controller = textViewController else { return } controller.moveLinesDown() } diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEditModules/Sources/CEEditor/Editor/EditorManager.swift similarity index 68% rename from CodeEdit/Features/Editor/Models/EditorManager.swift rename to CodeEditModules/Sources/CEEditor/Editor/EditorManager.swift index 4e8eae3493..d5f619612c 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEditModules/Sources/CEEditor/Editor/EditorManager.swift @@ -1,25 +1,30 @@ // -// TabManager.swift +// EditorManager.swift // CodeEdit // // Created by Wouter Hennen on 03/03/2023. // import Combine +import CodeEditDocument +import CodeEditCore import Foundation import DequeModule import os -class EditorManager: ObservableObject { +public class EditorManager: ObservableObject { let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "EditorManager") + /// Owns the file→document association for this workspace. + public let documents = DocumentRegistry() + /// The complete editor layout. - @Published var editorLayout: EditorLayout + @Published public var editorLayout: EditorLayout - @Published var isFocusingActiveEditor: Bool + @Published public var isFocusingActiveEditor: Bool /// The Editor with active focus. - @Published var activeEditor: Editor { + @Published public var activeEditor: Editor { didSet { activeEditorHistory.prepend { [weak oldValue] in oldValue } switchToActiveEditor() @@ -27,16 +32,16 @@ class EditorManager: ObservableObject { } /// History of last-used editors. - var activeEditorHistory: Deque<() -> Editor?> = [] + public var activeEditorHistory: Deque<() -> Editor?> = [] /// notify listeners whenever tab selection changes on the active editor. - var tabBarTabIdSubject = PassthroughSubject() + public var tabBarTabIdSubject = PassthroughSubject() var cancellable: AnyCancellable? // This caching mechanism is a temporary solution and is not optimized - @Published var updateCachedFlattenedEditors: Bool = true - var cachedFlettenedEditors: [Editor] = [] - var flattenedEditors: [Editor] { + @Published public var updateCachedFlattenedEditors: Bool = true + public var cachedFlettenedEditors: [Editor] = [] + public var flattenedEditors: [Editor] { if updateCachedFlattenedEditors { cachedFlettenedEditors = self.getFlattened() updateCachedFlattenedEditors = false @@ -46,29 +51,31 @@ class EditorManager: ObservableObject { // MARK: - Init - init() { + public init() { let tab = Editor() self.activeEditor = tab self.activeEditorHistory.prepend { [weak tab] in tab } self.editorLayout = .horizontal(.init(.horizontal, editorLayouts: [.one(tab)])) self.isFocusingActiveEditor = false + tab.editorManager = self switchToActiveEditor() } /// Initializes the editor manager's state to the "initial" state. /// /// Functionally identical to the initializer for this class. - func initCleanState() { + public func initCleanState() { let tab = Editor() self.activeEditor = tab self.activeEditorHistory.prepend { [weak tab] in tab } self.editorLayout = .horizontal(.init(.horizontal, editorLayouts: [.one(tab)])) self.isFocusingActiveEditor = false + tab.editorManager = self switchToActiveEditor() } /// Flattens the splitviews. - func flatten() { + public func flatten() { switch editorLayout { case .horizontal(let data), .vertical(let data): data.flatten() @@ -78,7 +85,7 @@ class EditorManager: ObservableObject { } /// Returns and array of flattened splitviews. - func getFlattened() -> [Editor] { + public func getFlattened() -> [Editor] { switch editorLayout { case .horizontal(let data), .vertical(let data): return data.getFlattened() @@ -92,13 +99,13 @@ class EditorManager: ObservableObject { /// - item: The tab to open. /// - editor: The editor to add the tab to. If nil, it is added to the active tab group. /// - asTemporary: Indicates whether the tab should be opened as a temporary tab or a permanent tab. - func openTab(item: CEWorkspaceFile, in editor: Editor? = nil, asTemporary: Bool = false) { + public func openTab(item: CEWorkspaceFile, in editor: Editor? = nil, asTemporary: Bool = false) { let editor = editor ?? activeEditor editor.openTab(file: item, asTemporary: asTemporary) } /// bind active tap group to listen to file selection changes. - func switchToActiveEditor() { + public func switchToActiveEditor() { cancellable?.cancel() cancellable = nil cancellable = activeEditor.$selectedTab @@ -111,7 +118,7 @@ class EditorManager: ObservableObject { /// Close an editor and fix editor manager state, updating active editor, etc. /// - Parameter editor: The editor to close - func closeEditor(_ editor: Editor) { + public func closeEditor(_ editor: Editor) { editor.close() if activeEditor == editor { setNewActiveEditor(excluding: editor) @@ -124,7 +131,7 @@ class EditorManager: ObservableObject { /// Set a new active editor. /// - Parameter editor: The editor to exclude. - func setNewActiveEditor(excluding editor: Editor) { + public func setNewActiveEditor(excluding editor: Editor) { activeEditorHistory.removeAll { $0() == nil || $0() == editor } if activeEditorHistory.isEmpty { activeEditor = findSomeEditor(excluding: editor) @@ -136,7 +143,7 @@ class EditorManager: ObservableObject { /// Find some editor, or if one cannot be found set up the editor manager with a clean state. /// - Parameter editor: The editor to exclude. /// - Returns: Some editor, order is not guaranteed. - func findSomeEditor(excluding editor: Editor) -> Editor { + public func findSomeEditor(excluding editor: Editor) -> Editor { guard let someEditor = editorLayout.findSomeEditor(except: editor) else { initCleanState() return activeEditor @@ -146,10 +153,29 @@ class EditorManager: ObservableObject { // MARK: - Focus - func toggleFocusingEditor(from editor: Editor) { + public func toggleFocusingEditor(from editor: Editor) { if !isFocusingActiveEditor { activeEditor = editor } isFocusingActiveEditor.toggle() } + + // MARK: - Documents + + public func document(for file: CEWorkspaceFile) -> CodeFileDocument? { + documents.document(for: file) + } + + public func setDocument(_ document: CodeFileDocument?, for file: CEWorkspaceFile) { + documents.setDocument(document, for: file) + } + + @discardableResult + public func loadDocument(for file: CEWorkspaceFile) throws -> CodeFileDocument { + try documents.loadDocument(for: file) + } + + public func documentPublisher(for file: CEWorkspaceFile) -> AnyPublisher { + documents.documentPublisher(for: file) + } } diff --git a/CodeEdit/Features/Editor/Views/AnyFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/AnyFileView.swift similarity index 100% rename from CodeEdit/Features/Editor/Views/AnyFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/AnyFileView.swift diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/CodeFileView.swift similarity index 73% rename from CodeEdit/Features/Editor/Views/CodeFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/CodeFileView.swift index f22f6cce3d..ce99e008cc 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEditModules/Sources/CEEditor/FileViews/CodeFileView.swift @@ -6,10 +6,14 @@ // import Foundation +import CodeEditSettings +import CodeEditDocument import SwiftUI +import CodeEditUI import CodeEditSourceEditor import CodeEditTextView import CodeEditLanguages +import CodeEditCore import Combine /// CodeFileView is just a wrapper of the `CodeEditor` dependency @@ -23,49 +27,49 @@ struct CodeFileView: View { private var textViewCoordinators: [TextViewCoordinator] private var highlightProviders: [any HighlightProviding] = [] - @AppSettings(\.textEditing.defaultTabWidth) + @SettingsValue(TextEditingSettings.self, \.defaultTabWidth) var defaultTabWidth - @AppSettings(\.textEditing.indentOption) + @SettingsValue(TextEditingSettings.self, \.indentOption) var indentOption - @AppSettings(\.textEditing.lineHeightMultiple) + @SettingsValue(TextEditingSettings.self, \.lineHeightMultiple) var lineHeightMultiple - @AppSettings(\.textEditing.wrapLinesToEditorWidth) + @SettingsValue(TextEditingSettings.self, \.wrapLinesToEditorWidth) var wrapLinesToEditorWidth - @AppSettings(\.textEditing.overscroll) + @SettingsValue(TextEditingSettings.self, \.overscroll) var overscroll - @AppSettings(\.textEditing.font) + @SettingsValue(TextEditingSettings.self, \.font) var settingsFont - @AppSettings(\.theme.useThemeBackground) + @SettingsValue(ThemeSettings.self, \.useThemeBackground) var useThemeBackground - @AppSettings(\.theme.matchAppearance) + @SettingsValue(ThemeSettings.self, \.matchAppearance) var matchAppearance - @AppSettings(\.textEditing.letterSpacing) + @SettingsValue(TextEditingSettings.self, \.letterSpacing) var letterSpacing - @AppSettings(\.textEditing.bracketEmphasis) + @SettingsValue(TextEditingSettings.self, \.bracketEmphasis) var bracketEmphasis - @AppSettings(\.textEditing.useSystemCursor) + @SettingsValue(TextEditingSettings.self, \.useSystemCursor) var useSystemCursor - @AppSettings(\.textEditing.showGutter) + @SettingsValue(TextEditingSettings.self, \.showGutter) var showGutter - @AppSettings(\.textEditing.showMinimap) + @SettingsValue(TextEditingSettings.self, \.showMinimap) var showMinimap - @AppSettings(\.textEditing.showFoldingRibbon) + @SettingsValue(TextEditingSettings.self, \.showFoldingRibbon) var showFoldingRibbon - @AppSettings(\.textEditing.reformatAtColumn) + @SettingsValue(TextEditingSettings.self, \.reformatAtColumn) var reformatAtColumn - @AppSettings(\.textEditing.showReformattingGuide) + @SettingsValue(TextEditingSettings.self, \.showReformattingGuide) var showReformattingGuide - @AppSettings(\.textEditing.invisibleCharacters) + @SettingsValue(TextEditingSettings.self, \.invisibleCharacters) var invisibleCharactersConfiguration - @AppSettings(\.textEditing.warningCharacters) + @SettingsValue(TextEditingSettings.self, \.warningCharacters) var warningCharacters @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var undoRegistration: UndoManagerRegistration + @EnvironmentObject var undoRegistry: UndoManagerRegistry - @ObservedObject private var themeModel: ThemeModel = .shared + @EnvironmentObject private var activeTheme: ActiveTheme @State private var treeSitter = TreeSitterClient() @@ -76,16 +80,19 @@ struct CodeFileView: View { init( editorInstance: EditorInstance, codeFile: CodeFileDocument, + languageServices languageServicesProvider: LanguageServicesProvider, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true ) { self._editorInstance = .init(wrappedValue: editorInstance) self._codeFile = .init(wrappedValue: codeFile) + let languageServices = languageServicesProvider.languageServices(for: codeFile) + self.textViewCoordinators = textViewCoordinators + [editorInstance.rangeTranslator] + [codeFile.contentCoordinator] - + [codeFile.languageServerObjects.textCoordinator] + + [languageServices.textCoordinator] self.isEditable = isEditable if let openOptions = codeFile.openOptions { @@ -93,7 +100,7 @@ struct CodeFileView: View { editorInstance.cursorPositions = openOptions.cursorPositions } - highlightProviders = [codeFile.languageServerObjects.highlightProvider] + [treeSitterClient] + highlightProviders = [languageServices.highlightProvider] + [treeSitterClient] codeFile .contentCoordinator @@ -105,11 +112,9 @@ struct CodeFileView: View { } private var currentTheme: Theme { - themeModel.selectedTheme ?? themeModel.themes.first! + activeTheme.current! } - @State private var font: NSFont = Settings[\.textEditing].font.current - @Environment(\.edgeInsets) private var edgeInsets @@ -121,17 +126,17 @@ struct CodeFileView: View { appearance: .init( theme: currentTheme.editor.editorTheme, useThemeBackground: useThemeBackground, - font: font, + font: settingsFont.current, lineHeightMultiple: lineHeightMultiple, letterSpacing: letterSpacing, - wrapLines: wrapLinesToEditorWidth, + wrapLines: codeFile.wrapLines ?? wrapLinesToEditorWidth, useSystemCursor: useSystemCursor, - tabWidth: defaultTabWidth, + tabWidth: codeFile.defaultTabWidth ?? defaultTabWidth, bracketPairEmphasis: getBracketPairEmphasis() ), behavior: .init( isEditable: isEditable, - indentOption: indentOption.textViewOption(), + indentOption: (codeFile.indentOption ?? indentOption).textViewOption(), reformatAtColumn: reformatAtColumn ), layout: .init( @@ -167,7 +172,7 @@ struct CodeFileView: View { } ), highlightProviders: highlightProviders, - undoManager: undoRegistration.manager(forFile: editorInstance.file), + undoManager: undoRegistry.manager(forFile: editorInstance.file), coordinators: textViewCoordinators ) // This view needs to refresh when the codefile changes. The file URL is too stable. @@ -182,21 +187,18 @@ struct CodeFileView: View { .colorScheme(currentTheme.appearance == .dark ? .dark : .light) // minHeight zero fixes a bug where the app would freeze if the contents of the file are empty. .frame(minHeight: .zero, maxHeight: .infinity) - .onChange(of: settingsFont) { _, newFontSetting in - font = newFontSetting.current - } } /// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme. /// - Returns: The emphasis style to use for bracket pair emphasis. private func getBracketPairEmphasis() -> BracketPairEmphasis? { - let color = if Settings[\.textEditing].bracketEmphasis.useCustomColor { - Settings[\.textEditing].bracketEmphasis.color.nsColor + let color = if bracketEmphasis.useCustomColor { + bracketEmphasis.color.nsColor } else { currentTheme.editor.text.nsColor.withAlphaComponent(0.8) } - switch Settings[\.textEditing].bracketEmphasis.highlightType { + switch bracketEmphasis.highlightType { case .disabled: return nil case .flash: @@ -211,18 +213,18 @@ struct CodeFileView: View { // This extension is kept here because it should not be used elsewhere in the app and may cause confusion // due to the similar type name from the CETV module. -private extension SettingsData.TextEditingSettings.IndentOption { - func textViewOption() -> IndentOption { +private extension TextEditingSettings.IndentOption { + func textViewOption() -> CodeEditSourceEditor.IndentOption { switch self.indentType { case .spaces: - return IndentOption.spaces(count: spaceCount) + return CodeEditSourceEditor.IndentOption.spaces(count: spaceCount) case .tab: - return IndentOption.tab + return CodeEditSourceEditor.IndentOption.tab } } } -private extension SettingsData.TextEditingSettings.InvisibleCharactersConfig { +private extension TextEditingSettings.InvisibleCharactersConfig { func textViewOption() -> InvisibleCharactersConfiguration { guard self.enabled else { return .empty } var config = InvisibleCharactersConfiguration( diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/EditorAreaFileView.swift similarity index 74% rename from CodeEdit/Features/Editor/Views/EditorAreaFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/EditorAreaFileView.swift index e4367dcc0a..42d2be98b7 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEditModules/Sources/CEEditor/FileViews/EditorAreaFileView.swift @@ -6,6 +6,8 @@ // import AppKit +import CodeEditDocument +import CodeEditUI import AVKit import CodeEditSourceEditor import SwiftUI @@ -14,11 +16,13 @@ struct EditorAreaFileView: View { @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var editor: Editor - @EnvironmentObject private var statusBarViewModel: StatusBarViewModel @Environment(\.edgeInsets) private var edgeInsets + @Environment(\.languageServices) + private var languageServices + var editorInstance: EditorInstance var codeFile: CodeFileDocument @@ -26,17 +30,13 @@ struct EditorAreaFileView: View { if let utType = codeFile.utType, utType.conforms(to: .text) { CodeFileView( editorInstance: editorInstance, - codeFile: codeFile + codeFile: codeFile, + languageServices: languageServices ) } else { NonTextFileView(fileDocument: codeFile) .padding(.top, edgeInsets.top - 1.74) - .padding(.bottom, StatusBarView.height + 1.26) - .modifier(UpdateStatusBarInfo(with: codeFile.fileURL)) - .onDisappear { - statusBarViewModel.dimensions = nil - statusBarViewModel.fileSize = nil - } + .padding(.bottom, LayoutMetrics.statusBarHeight + 1.26) } } diff --git a/CodeEditModules/Sources/CEEditor/FileViews/Environment+LanguageServices.swift b/CodeEditModules/Sources/CEEditor/FileViews/Environment+LanguageServices.swift new file mode 100644 index 0000000000..12def92888 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/FileViews/Environment+LanguageServices.swift @@ -0,0 +1,22 @@ +// +// Environment+LanguageServices.swift +// Editor +// +// Created by Matthijs Eikelenboom on 10/07/2026. +// + +import SwiftUI +import CodeEditDocument + +private struct LanguageServicesKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: LanguageServicesProvider = NoOpLanguageServicesProvider() +} + +extension EnvironmentValues { + /// Vends per-document language services (text coordinator + highlight provider). + /// No-op by default (previews render without LSP); injected by the app shell. + public var languageServices: LanguageServicesProvider { + get { self[LanguageServicesKey.self] } + set { self[LanguageServicesKey.self] = newValue } + } +} diff --git a/CodeEditModules/Sources/CEEditor/FileViews/FilePreviewView.swift b/CodeEditModules/Sources/CEEditor/FileViews/FilePreviewView.swift new file mode 100644 index 0000000000..aaec562033 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/FileViews/FilePreviewView.swift @@ -0,0 +1,46 @@ +// +// FilePreviewView.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 2026/07/09. +// + +import SwiftUI +import CodeEditDocument +import CodeEditCore + +public struct FilePreviewView: View { + private let item: CEWorkspaceFile + + @StateObject private var editorInstance: EditorInstance + @StateObject private var document: CodeFileDocument + @StateObject private var undoRegistry = UndoManagerRegistry() + + public init(item: CEWorkspaceFile) { + self.item = item + let doc = try? CodeFileDocument( + for: item.url, + withContentsOf: item.url, + ofType: item.contentType?.identifier ?? "public.source-code" + ) + self._editorInstance = .init(wrappedValue: EditorInstance(findReplaceQuery: nil, file: item)) + self._document = .init(wrappedValue: doc ?? .init()) + } + + @Environment(\.languageServices) + private var languageServices + + public var body: some View { + if let utType = document.utType, utType.conforms(to: .text) { + CodeFileView( + editorInstance: editorInstance, + codeFile: document, + languageServices: languageServices, + isEditable: false + ) + .environmentObject(undoRegistry) + } else { + NonTextFileView(fileDocument: document) + } + } +} diff --git a/CodeEdit/Features/Editor/Views/ImageFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/ImageFileView.swift similarity index 100% rename from CodeEdit/Features/Editor/Views/ImageFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/ImageFileView.swift diff --git a/CodeEdit/Features/Editor/Views/LoadingFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/LoadingFileView.swift similarity index 100% rename from CodeEdit/Features/Editor/Views/LoadingFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/LoadingFileView.swift diff --git a/CodeEdit/Features/Editor/Views/NonTextFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/NonTextFileView.swift similarity index 97% rename from CodeEdit/Features/Editor/Views/NonTextFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/NonTextFileView.swift index 36ca0cf5ef..e52f385cd1 100644 --- a/CodeEdit/Features/Editor/Views/NonTextFileView.swift +++ b/CodeEditModules/Sources/CEEditor/FileViews/NonTextFileView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditDocument /// Determines what type of file is passed in, and previews it accordingly. /// diff --git a/CodeEdit/Features/Editor/Views/PDFFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/PDFFileView.swift similarity index 95% rename from CodeEdit/Features/Editor/Views/PDFFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/PDFFileView.swift index 60058807a6..3400a05094 100644 --- a/CodeEdit/Features/Editor/Views/PDFFileView.swift +++ b/CodeEditModules/Sources/CEEditor/FileViews/PDFFileView.swift @@ -46,7 +46,7 @@ struct PDFFileView: NSViewRepresentable { /// - Returns: A modified `pdfView` if a valid PDF was created, or an unmodified `pdfView` if it could not create a /// valid PDF. @discardableResult - private func attachPDFDocumentToView (_ pdfView: PDFView) -> PDFView { + private func attachPDFDocumentToView(_ pdfView: PDFView) -> PDFView { guard let pdfDocument = PDFDocument(url: fileURL) else { // What can happen is the view doesn't redraw, so whatever was in the editor area view remains as is. return pdfView diff --git a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift b/CodeEditModules/Sources/CEEditor/FileViews/WindowCodeFileView.swift similarity index 64% rename from CodeEdit/Features/Editor/Views/WindowCodeFileView.swift rename to CodeEditModules/Sources/CEEditor/FileViews/WindowCodeFileView.swift index d53d1682f8..15d675e1bb 100644 --- a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift +++ b/CodeEditModules/Sources/CEEditor/FileViews/WindowCodeFileView.swift @@ -6,29 +6,34 @@ // import Foundation +import CodeEditDocument +import CodeEditCore import SwiftUI /// View that fixes [#1158](https://github.com/CodeEditApp/CodeEdit/issues/1158) /// # Should **not** be used other than in a single file window. -struct WindowCodeFileView: View { +public struct WindowCodeFileView: View { @StateObject var editorInstance: EditorInstance - @StateObject var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() + @StateObject var undoRegistry: UndoManagerRegistry = UndoManagerRegistry() var codeFile: CodeFileDocument - init(codeFile: CodeFileDocument) { + public init(codeFile: CodeFileDocument) { self._editorInstance = .init( wrappedValue: EditorInstance( - workspace: nil, + findReplaceQuery: nil, file: CEWorkspaceFile(url: codeFile.fileURL ?? URL(fileURLWithPath: "")) ) ) self.codeFile = codeFile } - var body: some View { + @Environment(\.languageServices) + private var languageServices + + public var body: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView(editorInstance: editorInstance, codeFile: codeFile) - .environmentObject(undoRegistration) + CodeFileView(editorInstance: editorInstance, codeFile: codeFile, languageServices: languageServices) + .environmentObject(undoRegistry) } else { NonTextFileView(fileDocument: codeFile) } diff --git a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarComponent.swift b/CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarComponent.swift similarity index 88% rename from CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarComponent.swift rename to CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarComponent.swift index c4f4ab0aaf..fca3097218 100644 --- a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarComponent.swift +++ b/CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarComponent.swift @@ -1,11 +1,13 @@ // -// EditorJumpBar.swift +// EditorJumpBarComponent.swift // CodeEdit // // Created by Lukas Pistrol on 18.03.22. // import SwiftUI +import CodeEditCore +import CodeEditSettings import Combine import CodeEditSymbols @@ -14,13 +16,17 @@ struct EditorJumpBarComponent: View { private let tappedOpenFile: (CEWorkspaceFile) -> Void private let isLastItem: Bool + @SettingsValue(GeneralSettings.self, \.fileIconStyle) + var fileIconStyle + @Environment(\.colorScheme) var colorScheme @Environment(\.controlActiveState) private var activeState - @EnvironmentObject var workspace: WorkspaceDocument + @Environment(\.workspaceFileProvider) + private var workspaceFileProvider @State var position: NSPoint? @State var selection: CEWorkspaceFile @@ -42,7 +48,7 @@ struct EditorJumpBarComponent: View { } var siblings: [CEWorkspaceFile] { - guard let fileManager = workspace.workspaceFileManager, + guard let fileManager = workspaceFileProvider, let parent = fileItem.parent else { return [fileItem] } @@ -55,11 +61,12 @@ struct EditorJumpBarComponent: View { var body: some View { NSPopUpButtonView(selection: $selection) { - guard let fileManager = workspace.workspaceFileManager else { return NSPopUpButton() } + guard let fileManager = workspaceFileProvider else { return NSPopUpButton() } button.menu = EditorJumpBarMenu( fileItems: siblings, fileManager: fileManager, + fileIconStyle: fileIconStyle, tappedOpenFile: tappedOpenFile ) button.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) @@ -169,6 +176,7 @@ struct EditorJumpBarComponent: View { return Coordinator(self) } + @MainActor class Coordinator: NSObject { var parent: NSPopUpButtonView @@ -185,7 +193,11 @@ struct EditorJumpBarComponent: View { .sink { [weak self] notification in if let menuItem = notification.userInfo?["MenuItem"] as? NSMenuItem, let selection = menuItem as? ItemType { - self?.parent.selection = selection + // AppKit posts `NSMenu.didSendActionNotification` on the main thread, + // so this delivery is main-thread and the binding may be written here. + MainActor.assumeIsolated { + self?.parent.selection = selection + } } } } diff --git a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarMenu.swift b/CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarMenu.swift similarity index 74% rename from CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarMenu.swift rename to CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarMenu.swift index 84c97766bc..f4570a4c6d 100644 --- a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarMenu.swift +++ b/CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarMenu.swift @@ -6,24 +6,35 @@ // import AppKit +import CodeEditSettings +import CodeEditCore final class EditorJumpBarMenu: NSMenu, NSMenuDelegate { private let fileItems: [CEWorkspaceFile] - private weak var fileManager: CEWorkspaceFileManager? + private weak var fileManager: (any WorkspaceFileProviding)? private let tappedOpenFile: (CEWorkspaceFile) -> Void + private let fileIconStyle: GeneralSettings.FileIconStyle + /// - Parameter fileIconStyle: Read at the SwiftUI boundary (`EditorJumpBarComponent`) and handed + /// in by value — this type is AppKit, so it cannot use `@SettingsValue` itself. init( fileItems: [CEWorkspaceFile], - fileManager: CEWorkspaceFileManager, + fileManager: any WorkspaceFileProviding, + fileIconStyle: GeneralSettings.FileIconStyle, tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.fileItems = fileItems self.fileManager = fileManager + self.fileIconStyle = fileIconStyle self.tappedOpenFile = tappedOpenFile super.init(title: "") delegate = self fileItems.forEach { item in - let menuItem = JumpBarMenuItem(fileItem: item, tappedOpenFile: tappedOpenFile) + let menuItem = JumpBarMenuItem( + fileItem: item, + fileIconStyle: fileIconStyle, + tappedOpenFile: tappedOpenFile + ) menuItem.onStateImage = nil self.addItem(menuItem) } @@ -50,6 +61,7 @@ final class EditorJumpBarMenu: NSMenu, NSMenuDelegate { let menu = EditorJumpBarMenu( fileItems: children, fileManager: fileManager, + fileIconStyle: fileIconStyle, tappedOpenFile: tappedOpenFile ) return menu @@ -61,10 +73,10 @@ final class EditorJumpBarMenu: NSMenu, NSMenuDelegate { final class JumpBarMenuItem: NSMenuItem { private let fileItem: CEWorkspaceFile private let tappedOpenFile: (CEWorkspaceFile) -> Void - private let generalSettings = Settings.shared.preferences.general init( fileItem: CEWorkspaceFile, + fileIconStyle: GeneralSettings.FileIconStyle, tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.fileItem = fileItem @@ -76,10 +88,10 @@ final class JumpBarMenuItem: NSMenuItem { if fileItem.isFolder { let subMenu = NSMenu() submenu = subMenu - color = NSColor.folderBlue + color = NSColor(named: "FolderBlue") ?? .systemBlue } - if generalSettings.fileIconStyle == .monochrome { - color = NSColor.coolGray + if fileIconStyle == .monochrome { + color = NSColor(named: "CoolGray") ?? .systemGray } let image = fileItem.nsIcon.withSymbolConfiguration(.init(paletteColors: [color])) self.image = image diff --git a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift b/CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarView.swift similarity index 99% rename from CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift rename to CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarView.swift index 9dda335439..4b7f82de88 100644 --- a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift +++ b/CodeEditModules/Sources/CEEditor/JumpBar/EditorJumpBarView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditDocument +import CodeEditCore struct EditorJumpBarView: View { private let file: CEWorkspaceFile? diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEditModules/Sources/CEEditor/Layout/EditorAreaView.swift similarity index 91% rename from CodeEdit/Features/Editor/Views/EditorAreaView.swift rename to CodeEditModules/Sources/CEEditor/Layout/EditorAreaView.swift index f2659969c6..ce1d38339e 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEditModules/Sources/CEEditor/Layout/EditorAreaView.swift @@ -6,17 +6,21 @@ // import SwiftUI +import CodeEditSettings +import CodeEditDocument +import CodeEditCore +import CodeEditUI import CodeEditTextView import UniformTypeIdentifiers struct EditorAreaView: View { - @AppSettings(\.general.showEditorJumpBar) + @SettingsValue(GeneralSettings.self, \.showEditorJumpBar) var showEditorJumpBar - @AppSettings(\.navigation.navigationStyle) + @SettingsValue(NavigationSettings.self, \.navigationStyle) var navigationStyle - @AppSettings(\.general.dimEditorsWithoutFocus) + @SettingsValue(GeneralSettings.self, \.dimEditorsWithoutFocus) var dimEditorsWithoutFocus @ObservedObject var editor: Editor @@ -36,9 +40,8 @@ struct EditorAreaView: View { init(editor: Editor, focus: FocusState.Binding) { self.editor = editor self._focus = focus - if let file = editor.selectedTab?.file.fileDocument { - self.codeFile = { [weak file] in file } - } + // `codeFile` is seeded from the environment's document registry in `body` + // (via `.onAppear` / the document publisher) — the environment is unavailable in `init`. } var body: some View { @@ -72,11 +75,11 @@ struct EditorAreaView: View { } else { LoadingFileView(selected.file.name) .onAppear { - if let file = selected.file.fileDocument { + if let file = editorManager.document(for: selected.file) { self.codeFile = { [weak file] in file } } } - .onReceive(selected.file.fileDocumentPublisher) { latestValue in + .onReceive(editorManager.documentPublisher(for: selected.file)) { latestValue in self.codeFile = { [weak latestValue] in latestValue } } } @@ -194,7 +197,7 @@ struct EditorAreaView: View { } } .onChange(of: editor.selectedTab) { _, newValue in - if let file = newValue?.file.fileDocument { + if let newValue, let file = editorManager.document(for: newValue.file) { codeFile = { [weak file] in file } } } diff --git a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift b/CodeEditModules/Sources/CEEditor/Layout/EditorLayout.swift similarity index 87% rename from CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift rename to CodeEditModules/Sources/CEEditor/Layout/EditorLayout.swift index ee803a76ab..c07e0e18c6 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift +++ b/CodeEditModules/Sources/CEEditor/Layout/EditorLayout.swift @@ -6,15 +6,16 @@ // import Foundation +import CodeEditCore -enum EditorLayout: Equatable { +public enum EditorLayout: Equatable { case one(Editor) case vertical(SplitViewData) case horizontal(SplitViewData) /// Closes all tabs which present the given file /// - Parameter file: a file. - func closeAllTabs(of file: CEWorkspaceFile) { + public func closeAllTabs(of file: CEWorkspaceFile) { switch self { case .one(let editor): editor.removeTab(file) @@ -28,7 +29,7 @@ enum EditorLayout: Equatable { /// Returns some editor, except the given editor. /// - Parameter except: the search will exclude this editor. /// - Returns: Some editor. - func findSomeEditor(except: Editor? = nil) -> Editor? { + public func findSomeEditor(except: Editor? = nil) -> Editor? { switch self { case .one(let editor) where editor != except: return editor @@ -44,7 +45,7 @@ enum EditorLayout: Equatable { } } - func find(editor id: UUID) -> Editor? { + public func find(editor id: UUID) -> Editor? { switch self { case .one(let editor): if editor.id == id { @@ -62,7 +63,7 @@ enum EditorLayout: Equatable { } /// Forms a set of all files currently represented by tabs. - func gatherOpenFiles() -> Set { + public func gatherOpenFiles() -> Set { switch self { case .one(let editor): return Set(editor.tabs.map { $0.file }) @@ -72,7 +73,7 @@ enum EditorLayout: Equatable { } /// Flattens the splitviews. - mutating func flatten(parent: SplitViewData) { + public mutating func flatten(parent: SplitViewData) { switch self { case .one: break @@ -90,7 +91,7 @@ enum EditorLayout: Equatable { } /// Gets flattened splitviews. - func getFlattened(parent: SplitViewData) -> [Editor] { + public func getFlattened(parent: SplitViewData) -> [Editor] { switch self { case .one(let editor): return [editor] @@ -107,7 +108,7 @@ enum EditorLayout: Equatable { } } - var isEmpty: Bool { + public var isEmpty: Bool { switch self { case .one: return false @@ -118,7 +119,7 @@ enum EditorLayout: Equatable { } } - static func == (lhs: EditorLayout, rhs: EditorLayout) -> Bool { + public static func == (lhs: EditorLayout, rhs: EditorLayout) -> Bool { switch (lhs, rhs) { case let (.one(lhs), .one(rhs)): return lhs == rhs diff --git a/CodeEdit/Features/Editor/Views/EditorLayoutView.swift b/CodeEditModules/Sources/CEEditor/Layout/EditorLayoutView.swift similarity index 79% rename from CodeEdit/Features/Editor/Views/EditorLayoutView.swift rename to CodeEditModules/Sources/CEEditor/Layout/EditorLayoutView.swift index aa7fa3252c..1ca49a5a4b 100644 --- a/CodeEdit/Features/Editor/Views/EditorLayoutView.swift +++ b/CodeEditModules/Sources/CEEditor/Layout/EditorLayoutView.swift @@ -6,9 +6,10 @@ // import SwiftUI +import CodeEditUI -struct EditorLayoutView: View { - var layout: EditorLayout +public struct EditorLayoutView: View { + public var layout: EditorLayout @FocusState.Binding var focus: Editor? @@ -22,7 +23,12 @@ struct EditorLayoutView: View { window?.contentView?.safeAreaInsets.top ?? .zero } - var body: some View { + public init(layout: EditorLayout, focus: FocusState.Binding) { + self.layout = layout + self._focus = focus + } + + public var body: some View { VStack { switch layout { case .one(let detailEditor): @@ -31,11 +37,11 @@ struct EditorLayoutView: View { switch isAtEdge { case .all: insets.top += toolbarHeight - insets.bottom += StatusBarView.height + 5 + insets.bottom += LayoutMetrics.statusBarHeight + 5 case .top: insets.top += toolbarHeight case .bottom: - insets.bottom += StatusBarView.height + 5 + insets.bottom += LayoutMetrics.statusBarHeight + 5 default: return } @@ -87,11 +93,12 @@ struct EditorLayoutView: View { } } -struct BelowToolbarEnvironmentKey: EnvironmentKey { - static var defaultValue: VerticalEdge.Set = .all +public struct BelowToolbarEnvironmentKey: EnvironmentKey { + nonisolated(unsafe) public static var defaultValue: VerticalEdge.Set = .all } -extension EnvironmentValues { +public extension EnvironmentValues { + /// The vertical edges at which this editor layout borders the window, used to adjust chrome near the toolbar. var isEditorLayoutAtEdge: BelowToolbarEnvironmentKey.Value { get { self[BelowToolbarEnvironmentKey.self] } set { self[BelowToolbarEnvironmentKey.self] = newValue } diff --git a/CodeEdit/Features/SplitView/Model/Environment+SplitEditor.swift b/CodeEditModules/Sources/CEEditor/Layout/Environment+SplitEditor.swift similarity index 50% rename from CodeEdit/Features/SplitView/Model/Environment+SplitEditor.swift rename to CodeEditModules/Sources/CEEditor/Layout/Environment+SplitEditor.swift index 1fd3eb14ac..e8e2956bbd 100644 --- a/CodeEdit/Features/SplitView/Model/Environment+SplitEditor.swift +++ b/CodeEditModules/Sources/CEEditor/Layout/Environment+SplitEditor.swift @@ -7,11 +7,12 @@ import SwiftUI -struct SplitEditorEnvironmentKey: EnvironmentKey { - static var defaultValue: (Edge, Editor) -> Void = { _, _ in } +public struct SplitEditorEnvironmentKey: EnvironmentKey { + nonisolated(unsafe) public static var defaultValue: (Edge, Editor) -> Void = { _, _ in } } -extension EnvironmentValues { +public extension EnvironmentValues { + /// A closure that splits the current editor towards the given edge, inserting the provided editor. var splitEditor: SplitEditorEnvironmentKey.Value { get { self[SplitEditorEnvironmentKey.self] } set { self[SplitEditorEnvironmentKey.self] = newValue } diff --git a/CodeEdit/Features/SplitView/Model/SplitViewData.swift b/CodeEditModules/Sources/CEEditor/Layout/SplitViewData.swift similarity index 84% rename from CodeEdit/Features/SplitView/Model/SplitViewData.swift rename to CodeEditModules/Sources/CEEditor/Layout/SplitViewData.swift index a874085764..987dc1ebd5 100644 --- a/CodeEdit/Features/SplitView/Model/SplitViewData.swift +++ b/CodeEditModules/Sources/CEEditor/Layout/SplitViewData.swift @@ -7,12 +7,12 @@ import SwiftUI -final class SplitViewData: ObservableObject { - @Published var editorLayouts: [EditorLayout] +public final class SplitViewData: ObservableObject { + @Published public var editorLayouts: [EditorLayout] - var axis: Axis + public var axis: Axis - init(_ axis: Axis, editorLayouts: [EditorLayout] = []) { + public init(_ axis: Axis, editorLayouts: [EditorLayout] = []) { self.editorLayouts = editorLayouts self.axis = axis @@ -30,7 +30,7 @@ final class SplitViewData: ObservableObject { /// the editor is added to the ancestor instead of creating a new split container. /// - index: index where the divider will be added. /// - editor: new editor class that will be used for the editor. - func split(_ direction: Edge, at index: Int, new editor: Editor) { + public func split(_ direction: Edge, at index: Int, new editor: Editor) { editor.parent = self switch (axis, direction) { case (.horizontal, .trailing), (.vertical, .bottom): @@ -55,7 +55,7 @@ final class SplitViewData: ObservableObject { /// Closes an Editor. /// - Parameter id: ID of the Editor. - func closeEditor(with id: Editor.ID) { + public func closeEditor(with id: Editor.ID) { editorLayouts.removeAll { editorLayout in if case .one(let editor) = editorLayout { if editor.id == id { @@ -67,7 +67,7 @@ final class SplitViewData: ObservableObject { } } - func getEditorLayout(with id: Editor.ID) -> EditorLayout? { + public func getEditorLayout(with id: Editor.ID) -> EditorLayout? { for editorLayout in editorLayouts { if case .one(let editor) = editorLayout { if editor.id == id { @@ -80,14 +80,14 @@ final class SplitViewData: ObservableObject { } /// Flattens the splitviews. - func flatten() { + public func flatten() { for index in editorLayouts.indices { editorLayouts[index].flatten(parent: self) } } /// Gets flattened splitviews. - func getFlattened() -> [Editor] { + public func getFlattened() -> [Editor] { var arr: [Editor] = [] for index in editorLayouts.indices { arr += editorLayouts[index].getFlattened(parent: self) diff --git a/CodeEditModules/Sources/CEEditor/Restoration/EditorLayout+StateRestoration.swift b/CodeEditModules/Sources/CEEditor/Restoration/EditorLayout+StateRestoration.swift new file mode 100644 index 0000000000..e6ce2f28dd --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Restoration/EditorLayout+StateRestoration.swift @@ -0,0 +1,182 @@ +// +// EditorLayout+StateRestoration.swift +// CodeEdit +// +// Created by Khan Winter on 7/3/23. +// + +import Foundation +import CodeEditCore +import SwiftUI +import OrderedCollections + +extension EditorManager { + /// Restores the tab manager from a captured state obtained using `saveRestorationState` + /// - Parameters: + /// - statePersistence: The persistence service to retrieve saved state from. + /// - fileManager: The file manager to resolve file references. + /// - findReplaceQuery: The shared find/replace query for editor instances. + public func restoreFromState( + statePersistence: any WorkspaceStatePersisting, + fileManager: (any WorkspaceFileProviding)?, + findReplaceQuery: FindReplaceQuery? + ) { + defer { + // No matter what, set up each editor. Even if we fail to read data. + flattenedEditors.forEach { editor in + editor.findReplaceQuery = findReplaceQuery + editor.editorManager = self + editor.isAttachedToWorkspace = true + } + } + + let restorer = EditorRestorer() + switch restorer.execute( + statePersistence: statePersistence, + fileManager: fileManager, + findReplaceQuery: findReplaceQuery, + editorManager: self + ) { + case let .restored(layout, activeEditor): + self.editorLayout = layout + self.activeEditor = activeEditor + switchToActiveEditor() + case .shouldInitCleanState: + initCleanState() + case .noChange: + break + } + } + + /// Encodes the current editor layout and active editor, storing it with the persistence service + /// for `restoreFromState` to load on the next launch. + /// - Parameter statePersistence: The persistence service to save the captured state to. + public func saveRestorationState(_ statePersistence: any WorkspaceStatePersisting) { + if let data = try? JSONEncoder().encode( + EditorRestorationState(activeEditor: activeEditor.id, groups: editorLayout) + ) { + statePersistence.set(key: .openTabs, value: data) + } else { + statePersistence.set(key: .openTabs, value: nil) + } + } +} + +public struct EditorRestorationState: Codable { + public var activeEditor: UUID + public var groups: EditorLayout +} + +extension EditorLayout: Codable { + fileprivate enum EditorLayoutType: String, Codable { + case one + case vertical + case horizontal + } + + public enum CodingKeys: String, CodingKey { + case type + case tabs + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(EditorLayoutType.self, forKey: .type) + switch type { + case .one: + let editor = try container.decode(Editor.self, forKey: .tabs) + self = .one(editor) + case .vertical: + let editor = try container.decode(SplitViewData.self, forKey: .tabs) + self = .vertical(editor) + case .horizontal: + let editor = try container.decode(SplitViewData.self, forKey: .tabs) + self = .horizontal(editor) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .one(data): + try container.encode(EditorLayoutType.one, forKey: .type) + try container.encode(data, forKey: .tabs) + case let .vertical(data): + try container.encode(EditorLayoutType.vertical, forKey: .type) + try container.encode(data, forKey: .tabs) + case let .horizontal(data): + try container.encode(EditorLayoutType.horizontal, forKey: .type) + try container.encode(data, forKey: .tabs) + } + } +} + +extension SplitViewData: Codable { + fileprivate enum SplitViewAxis: String, Codable { + case vertical, horizontal + + init(_ swiftUI: Axis) { + switch swiftUI { + case .vertical: self = .vertical + case .horizontal: self = .horizontal + } + } + + var swiftUI: Axis { + switch self { + case .vertical: return .vertical + case .horizontal: return .horizontal + } + } + } + + public enum CodingKeys: String, CodingKey { + case editorLayouts + case axis + } + + public convenience init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let axis = try container.decode(SplitViewAxis.self, forKey: .axis).swiftUI + let editorLayouts = try container.decode([EditorLayout].self, forKey: .editorLayouts) + self.init(axis, editorLayouts: editorLayouts) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(editorLayouts, forKey: .editorLayouts) + try container.encode(SplitViewAxis(axis), forKey: .axis) + } +} + +extension Editor: Codable { + public enum CodingKeys: String, CodingKey { + case tabs + case selectedTab + case id + } + + public convenience init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fileURLs = try container.decode([URL].self, forKey: .tabs) + let selectedTab = try? container.decode(URL.self, forKey: .selectedTab) + let id = try container.decode(UUID.self, forKey: .id) + self.init( + files: OrderedSet(fileURLs.map { CEWorkspaceFile(url: $0) }), + selectedTab: selectedTab == nil ? nil : EditorInstance( + findReplaceQuery: nil, + file: CEWorkspaceFile(url: selectedTab!) + ), + parent: nil, + findReplaceQuery: nil + ) + self.id = id + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tabs.map { $0.file.url }, forKey: .tabs) + try container.encode(selectedTab?.file.url, forKey: .selectedTab) + try container.encode(id, forKey: .id) + } +} diff --git a/CodeEditModules/Sources/CEEditor/Restoration/EditorRestorer.swift b/CodeEditModules/Sources/CEEditor/Restoration/EditorRestorer.swift new file mode 100644 index 0000000000..8345065f10 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Restoration/EditorRestorer.swift @@ -0,0 +1,131 @@ +// +// EditorRestorer.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/04/26. +// + +import Foundation +import CodeEditCore +import OSLog +import OrderedCollections + +/// Restores an editor layout from persisted state, resolving file references against the current file manager. +public final class EditorRestorer { + + /// The result of a restoration attempt, telling the caller how to proceed. + public enum Outcome { + /// Persisted state was loaded and resolved successfully. + case restored(layout: EditorLayout, activeEditor: Editor) + /// Persisted state was found but is empty/invalid; caller should initialize a clean state. + case shouldInitCleanState + /// No persisted state exists, or decoding failed; caller should leave the editor as-is. + case noChange + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "EditorRestorer") + + /// Decodes persisted editor state, validates it, and resolves file references. + public func execute( + statePersistence: any WorkspaceStatePersisting, + fileManager: (any WorkspaceFileProviding)?, + findReplaceQuery: FindReplaceQuery?, + editorManager: EditorManager + ) -> Outcome { + guard let data = statePersistence.get(.openTabs) as? Data else { + return .noChange + } + + do { + let state = try JSONDecoder().decode(EditorRestorationState.self, from: data) + + guard !state.groups.isEmpty else { + logger.warning("Empty Editor State found, restoring to clean editor state.") + return .shouldInitCleanState + } + + guard let activeEditor = state.groups.find( + editor: state.activeEditor + ) ?? state.groups.findSomeEditor() else { + logger.warning("Editor state could not restore active editor.") + return .shouldInitCleanState + } + + try fixRestoredEditorLayout( + state.groups, + fileManager: fileManager, + findReplaceQuery: findReplaceQuery, + editorManager: editorManager + ) + + return .restored(layout: state.groups, activeEditor: activeEditor) + } catch { + logger.warning( + "Could not restore editor state from saved data: \(error.localizedDescription, privacy: .public)" + ) + return .noChange + } + } + + /// Recursively maps decoded `CEWorkspaceFile` references to their shared file-manager-owned representations. + private func fixRestoredEditorLayout( + _ group: EditorLayout, + fileManager: (any WorkspaceFileProviding)?, + findReplaceQuery: FindReplaceQuery?, + editorManager: EditorManager + ) throws { + switch group { + case let .one(data): + try fixEditor( + data, fileManager: fileManager, findReplaceQuery: findReplaceQuery, editorManager: editorManager + ) + case let .vertical(splitData): + try splitData.editorLayouts.forEach { group in + try fixRestoredEditorLayout( + group, fileManager: fileManager, findReplaceQuery: findReplaceQuery, editorManager: editorManager + ) + } + case let .horizontal(splitData): + try splitData.editorLayouts.forEach { group in + try fixRestoredEditorLayout( + group, fileManager: fileManager, findReplaceQuery: findReplaceQuery, editorManager: editorManager + ) + } + } + } + + /// Resolves all file references on a single editor with the workspace's file manager + /// and loads each tab's underlying code file. + private func fixEditor( + _ editor: Editor, + fileManager: (any WorkspaceFileProviding)?, + findReplaceQuery: FindReplaceQuery?, + editorManager: EditorManager + ) throws { + guard let fileManager else { return } + let resolvedTabs = editor + .tabs + .compactMap({ fileManager.getFile($0.file.url.path(percentEncoded: false), createIfNotFound: true) }) + .map({ EditorInstance(findReplaceQuery: findReplaceQuery, file: $0) }) + + for tab in resolvedTabs { + try editorManager.loadDocument(for: tab.file) + } + + editor.findReplaceQuery = findReplaceQuery + editor.editorManager = editorManager + editor.isAttachedToWorkspace = true + editor.tabs = OrderedSet(resolvedTabs) + + if let selectedTab = editor.selectedTab { + if let resolvedFile = fileManager.getFile( + selectedTab.file.url.path(percentEncoded: false), + createIfNotFound: true + ) { + editor.setSelectedTab(resolvedFile) + } else { + editor.setSelectedTab(nil) + } + } + } +} diff --git a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift b/CodeEditModules/Sources/CEEditor/Restoration/EditorStateRestoration.swift similarity index 83% rename from CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift rename to CodeEditModules/Sources/CEEditor/Restoration/EditorStateRestoration.swift index 4b375ac887..f0c9a3d33a 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift +++ b/CodeEditModules/Sources/CEEditor/Restoration/EditorStateRestoration.swift @@ -5,6 +5,7 @@ // Created by Khan Winter on 6/20/25. // +import CodeEditCore import Foundation import GRDB import CodeEditSourceEditor @@ -22,37 +23,36 @@ import OSLog /// /// Use the database migrator in the initializer for this class, see GRDB's documentation for adding a migration /// version. **Do not ever** delete migration versions that have made it to a released version of CodeEdit. -final class EditorStateRestoration { +public final class EditorStateRestoration { /// Optional here so we can gracefully catch errors. /// The nice thing is this feature is optional in that if we don't have it available the user's experience is /// degraded but not catastrophic. - static let shared: EditorStateRestoration? = try? EditorStateRestoration() + nonisolated(unsafe) public static let shared: EditorStateRestoration? = try? EditorStateRestoration() private static let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "", category: "EditorStateRestoration" ) - struct StateRestorationRecord: Codable, TableRecord, FetchableRecord, PersistableRecord { - let uri: String - let data: Data + public struct StateRestorationRecord: Codable, TableRecord, FetchableRecord, PersistableRecord { + public let uri: String + public let data: Data } - struct StateRestorationData: Codable, Equatable { - // Cursor positions as range values (not row/column!) - let cursorPositions: [Range] - let scrollPositionX: Double - let scrollPositionY: Double + public struct StateRestorationData: Codable, Equatable { + public let cursorPositions: [Range] + public let scrollPositionX: Double + public let scrollPositionY: Double - var scrollPosition: CGPoint { + public var scrollPosition: CGPoint { CGPoint(x: scrollPositionX, y: scrollPositionY) } - var editorCursorPositions: [CursorPosition] { + public var editorCursorPositions: [CursorPosition] { cursorPositions.map { CursorPosition(range: NSRange(start: $0.lowerBound, end: $0.upperBound)) } } - init(cursorPositions: [CursorPosition], scrollPosition: CGPoint) { + public init(cursorPositions: [CursorPosition], scrollPosition: CGPoint) { self.cursorPositions = cursorPositions .compactMap { $0.range } .map { $0.location..<($0.location + $0.length) } @@ -68,7 +68,7 @@ final class EditorStateRestoration { /// - Parameter databaseURL: The database URL to use. Must point to a file, not a directory. If left `nil`, will /// create a new database named `editor-restoration.db` in the application support /// directory. - init(_ databaseURL: URL? = nil) throws { + public init(_ databaseURL: URL? = nil) throws { self.databaseURL = databaseURL ?? FileManager.default .homeDirectoryForCurrentUser .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) @@ -108,7 +108,7 @@ final class EditorStateRestoration { /// - Parameters: /// - documentUrl: The URL of the document. /// - data: The data to store for the file, retrieved using ``restorationState(for:)``. - func updateRestorationState(for documentUrl: URL, data: StateRestorationData) { + public func updateRestorationState(for documentUrl: URL, data: StateRestorationData) { do { let serializedData = try JSONEncoder().encode(data) let dbRow = StateRestorationRecord(uri: documentUrl.absolutePath, data: serializedData) @@ -121,7 +121,7 @@ final class EditorStateRestoration { /// Find the restoration state for a document. /// - Parameter documentUrl: The URL of the document. /// - Returns: Any data saved for this file. - func restorationState(for documentUrl: URL) -> StateRestorationData? { + public func restorationState(for documentUrl: URL) -> StateRestorationData? { do { guard let row = try databaseQueue?.read({ try StateRestorationRecord.fetchOne($0, key: documentUrl.absolutePath) diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorHistoryMenus.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorHistoryMenus.swift similarity index 99% rename from CodeEdit/Features/Editor/TabBar/Views/EditorHistoryMenus.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorHistoryMenus.swift index f223f69c25..44b41d0a1c 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorHistoryMenus.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/EditorHistoryMenus.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct EditorHistoryMenus: View { @EnvironmentObject private var editorManager: EditorManager diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarAccessory.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarAccessory.swift similarity index 93% rename from CodeEdit/Features/Editor/TabBar/Views/EditorTabBarAccessory.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarAccessory.swift index a8fb5f0c2b..ec7b214ea6 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarAccessory.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarAccessory.swift @@ -1,11 +1,12 @@ // -// TabBarAccessory.swift +// EditorTabBarAccessory.swift // CodeEdit // // Created by Lingxi Li on 4/28/22. // import SwiftUI +import CodeEditUI /// Accessory icon's view for tab bar. struct EditorTabBarAccessoryIcon: View { diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarContextMenu.swift similarity index 88% rename from CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarContextMenu.swift index 29e539cf14..31cc8dda26 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarContextMenu.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore import Foundation extension View { @@ -23,7 +24,13 @@ struct EditorTabBarContextMenu: ViewModifier { self.isTemporary = isTemporary } - @EnvironmentObject var workspace: WorkspaceDocument + @EnvironmentObject var editorManager: EditorManager + + @Environment(\.workspaceNavigator) + private var workspaceNavigator + + @Environment(\.workspaceFileProvider) + private var workspaceFileProvider @EnvironmentObject var tabs: Editor @@ -97,11 +104,11 @@ struct EditorTabBarContextMenu: ViewModifier { Group { Button("Show in Finder") { - item.showInFinder() + NSWorkspace.shared.activateFileViewerSelecting([item.url]) } Button("Reveal in Project Navigator") { - workspace.listenerModel.highlightedFileItem = item + workspaceNavigator.reveal(file: item) } Button("Open in New Window") { @@ -137,16 +144,17 @@ struct EditorTabBarContextMenu: ViewModifier { } func moveToNewSplit(_ edge: Edge) { - let newEditor = Editor(files: [item], workspace: workspace) + let newEditor = Editor(files: [item], findReplaceQuery: tabs.findReplaceQuery) + newEditor.editorManager = editorManager splitEditor(edge, newEditor) tabs.closeTab(file: item) - workspace.editorManager?.activeEditor = newEditor + editorManager.activeEditor = newEditor } /// Copies the relative path from the workspace folder to the given file item to the pasteboard. /// - Parameter item: The `FileItem` to use. private func copyRelativePath(item: CEWorkspaceFile) { - guard let rootPath = workspace.workspaceFileManager?.folderUrl else { + guard let rootPath = workspaceFileProvider?.folderUrl else { return } let destinationComponents = item.url.standardizedFileURL.pathComponents diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarDivider.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarDivider.swift similarity index 100% rename from CodeEdit/Features/Editor/TabBar/Views/EditorTabBarDivider.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarDivider.swift diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarLeadingAccessories.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarLeadingAccessories.swift similarity index 100% rename from CodeEdit/Features/Editor/TabBar/Views/EditorTabBarLeadingAccessories.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarLeadingAccessories.swift diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarTrailingAccessories.swift similarity index 82% rename from CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarTrailingAccessories.swift index 8333856606..1fa17ffd79 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarTrailingAccessories.swift @@ -6,11 +6,14 @@ // import SwiftUI +import CodeEditSettings +import CodeEditDocument +import CodeEditUI struct EditorTabBarTrailingAccessories: View { - @AppSettings(\.textEditing.wrapLinesToEditorWidth) + @SettingsValue(TextEditingSettings.self, \.wrapLinesToEditorWidth) var wrapLinesToEditorWidth - @AppSettings(\.textEditing.showMinimap) + @SettingsValue(TextEditingSettings.self, \.showMinimap) var showMinimap @Environment(\.splitEditor) @@ -22,8 +25,6 @@ struct EditorTabBarTrailingAccessories: View { @Environment(\.controlActiveState) private var activeState - @EnvironmentObject var workspace: WorkspaceDocument - @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var editor: Editor @@ -99,10 +100,11 @@ struct EditorTabBarTrailingAccessories: View { func split(edge: Edge) { let newEditor: Editor if let tab = editor.selectedTab { - newEditor = .init(files: [tab], temporaryTab: tab, workspace: workspace) + newEditor = .init(files: [tab], temporaryTab: tab, findReplaceQuery: editor.findReplaceQuery) } else { newEditor = .init() } + newEditor.editorManager = editorManager splitEditor(edge, newEditor) editorManager.updateCachedFlattenedEditors = true editorManager.activeEditor = newEditor @@ -110,7 +112,15 @@ struct EditorTabBarTrailingAccessories: View { } struct TabBarTrailingAccessories_Previews: PreviewProvider { + /// A store on a temporary file, never the user's real `settings.json`: this view writes through + /// `@SettingsValue`, and a preview must not be able to persist over real settings. + private static let store = PersistentSettingsStore( + settingsURL: FileManager.default.temporaryDirectory + .appendingPathComponent("preview-settings.json") + ) + static var previews: some View { EditorTabBarTrailingAccessories(codeFile: .constant(nil)) + .environmentObject(store) } } diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarView.swift similarity index 97% rename from CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift rename to CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarView.swift index e080d1dffc..cb13743083 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/EditorTabBarView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditDocument struct EditorTabBarView: View { let hasTopInsets: Bool diff --git a/CodeEditModules/Sources/CEEditor/TabBar/Tab/CEWorkspaceFile+Editor.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/CEWorkspaceFile+Editor.swift new file mode 100644 index 0000000000..885988c416 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tab/CEWorkspaceFile+Editor.swift @@ -0,0 +1,29 @@ +// +// CEWorkspaceFile+Editor.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import SwiftUI +import CodeEditCore +import CodeEditUI + +extension CEWorkspaceFile: EditorTabRepresentable { + public var tabID: EditorTabID { .codeEditor(id) } + + /// Symbol + tint for this file or folder, from ``CodeEditUI/FileIcon``. + var iconSpec: FileIconSpec { + isFolder + ? FileIcon.folderSpec( + isEmpty: isEmptyFolder, + isRoot: parent == nil, + isCodeEditDirectory: name == ".codeedit" + ) + : FileIcon.spec(for: url) + } + + var icon: Image { iconSpec.image } + var nsIcon: NSImage { iconSpec.nsImage } + var iconColor: Color { iconSpec.color } +} diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorFileTabCloseButton.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorFileTabCloseButton.swift similarity index 70% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorFileTabCloseButton.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorFileTabCloseButton.swift index a53d4d1e6a..5b4904cef8 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorFileTabCloseButton.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorFileTabCloseButton.swift @@ -1,11 +1,12 @@ // -// FileEditorTabCloseButton.swift +// EditorFileTabCloseButton.swift // CodeEdit // // Created by Albert Vinizhanau on 10/13/23. // import Foundation +import CodeEditCore import SwiftUI import Combine @@ -21,6 +22,8 @@ struct EditorFileTabCloseButton: View { @State private var isDocumentEdited: Bool = false @State private var id: Int = 0 + @EnvironmentObject private var editorManager: EditorManager + var body: some View { EditorTabCloseButton( isActive: isActive, @@ -32,13 +35,14 @@ struct EditorFileTabCloseButton: View { isHoveringClose: $isHoveringClose ) .id(id) - // Detects if file document changed, when this view created item.fileDocument is nil - .onReceive(item.fileDocumentPublisher, perform: { _ in + // Detects if the file's document changed; when this view is created the document may be nil + .onReceive(editorManager.documentPublisher(for: item), perform: { _ in // Force re-render so isDocumentEdited publisher is updated self.id += 1 }) .onReceive( - item.fileDocument?.isDocumentEditedPublisher.eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() + editorManager.document(for: item)?.isDocumentEditedPublisher.eraseToAnyPublisher() + ?? Empty().eraseToAnyPublisher() ) { newValue in self.isDocumentEdited = newValue } diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabBackground.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabBackground.swift similarity index 99% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabBackground.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabBackground.swift index 91c9ccd514..e1fc801298 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabBackground.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabBackground.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct EditorTabBackground: View { var isActive: Bool diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabButtonStyle.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabButtonStyle.swift similarity index 100% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabButtonStyle.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabButtonStyle.swift diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabCloseButton.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabCloseButton.swift similarity index 100% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabCloseButton.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabCloseButton.swift diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabFileObserver.swift similarity index 93% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabFileObserver.swift index c9e78d7886..8d5def90d6 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabFileObserver.swift @@ -6,12 +6,13 @@ // import Foundation +import CodeEditCore import SwiftUI /// Observer ViewModel for tracking file deletion @MainActor final class EditorTabFileObserver: ObservableObject, - CEWorkspaceFileManagerObserver { + WorkspaceFileObserver { @Published private(set) var isDeleted = false private let tabFile: CEWorkspaceFile diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabRepresentable.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabRepresentable.swift similarity index 95% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabRepresentable.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabRepresentable.swift index 771de2067d..ed74da4211 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabRepresentable.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabRepresentable.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore /// Protocol for data passed to EditorTabView to conform to protocol EditorTabRepresentable { diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabView.swift similarity index 95% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabView.swift index 006716749e..45a61f4351 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tab/EditorTabView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditSettings +import CodeEditCore struct EditorTabView: View { @@ -21,12 +23,14 @@ struct EditorTabView: View { @Environment(\.isFullscreen) private var isFullscreen - @EnvironmentObject var workspace: WorkspaceDocument @EnvironmentObject private var editorManager: EditorManager + @Environment(\.workspaceFileProvider) + private var workspaceFileProvider + @StateObject private var fileObserver: EditorTabFileObserver - @AppSettings(\.general.fileIconStyle) + @SettingsValue(GeneralSettings.self, \.fileIconStyle) var fileIconStyle /// Is cursor hovering over the entire tab. @@ -92,7 +96,7 @@ struct EditorTabView: View { // Only set the `selectedId` when they are not equal to avoid performance issue for now. editorManager.activeEditor = editor if editor.selectedTab?.file != tabFile { - let tabItem = EditorInstance(workspace: workspace, file: tabFile) + let tabItem = EditorInstance(findReplaceQuery: editor.findReplaceQuery, file: tabFile) editor.setSelectedTab(tabFile) editor.clearFuture() editor.addToHistory(tabItem) @@ -265,10 +269,10 @@ struct EditorTabView: View { .tabBarContextMenu(item: tabFile, isTemporary: isTemporary) .accessibilityElement(children: .contain) .onAppear { - workspace.workspaceFileManager?.addObserver(fileObserver) + workspaceFileProvider?.addObserver(fileObserver) } .onDisappear { - workspace.workspaceFileManager?.removeObserver(fileObserver) + workspaceFileProvider?.removeObserver(fileObserver) } } } diff --git a/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabOnDropDelegate.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabOnDropDelegate.swift new file mode 100644 index 0000000000..e0a11eb0c2 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabOnDropDelegate.swift @@ -0,0 +1,68 @@ +// +// EditorTabOnDropDelegate.swift +// CodeEdit +// +// Created by Austin Condiff on 9/7/23. +// + +import SwiftUI +import CodeEditCore + +struct EditorTabOnDropDelegate: DropDelegate { + typealias TabID = CEWorkspaceFile.ID + + private let currentTabId: TabID + @Binding private var openedTabs: [TabID] + @Binding private var onDragTabId: TabID? + @Binding private var onDragLastLocation: CGPoint? + @Binding private var isOnDragOverTabs: Bool + @Binding private var tabWidth: [TabID: CGFloat] + + public init( + currentTabId: TabID, + openedTabs: Binding<[TabID]>, + onDragTabId: Binding, + onDragLastLocation: Binding, + isOnDragOverTabs: Binding, + tabWidth: Binding<[TabID: CGFloat]> + ) { + self.currentTabId = currentTabId + self._openedTabs = openedTabs + self._onDragTabId = onDragTabId + self._onDragLastLocation = onDragLastLocation + self._isOnDragOverTabs = isOnDragOverTabs + self._tabWidth = tabWidth + } + + func dropEntered(info: DropInfo) { + isOnDragOverTabs = true + guard let onDragTabId, + currentTabId != onDragTabId, + let from = openedTabs.firstIndex(of: onDragTabId), + let toIndex = openedTabs.firstIndex(of: currentTabId) + else { return } + if openedTabs[toIndex] != onDragTabId { + withAnimation { + openedTabs.move( + fromOffsets: IndexSet(integer: from), + toOffset: toIndex > from ? toIndex + 1 : toIndex + ) + } + } + } + + func dropExited(info: DropInfo) { + // Do nothing. + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + isOnDragOverTabs = false + onDragTabId = nil + onDragLastLocation = nil + return true + } +} diff --git a/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabs+DragGesture.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabs+DragGesture.swift new file mode 100644 index 0000000000..e9c7b51ab1 --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabs+DragGesture.swift @@ -0,0 +1,131 @@ +// +// EditorTabs+DragGesture.swift +// CodeEdit +// +// Created by Austin Condiff on 9/7/23. +// + +import SwiftUI + +// Disable the rule because this function is implementing the drag gesture and its animations. +// It is fairly complicated, so ignore the function body length limitation for now. +// swiftlint:disable function_body_length cyclomatic_complexity + +extension EditorTabs { + func makeTabDragGesture(id: TabID) -> some Gesture { + return DragGesture(minimumDistance: 2, coordinateSpace: .global) + .onChanged({ value in + if closeButtonGestureActive { + return + } + + if draggingTabId != id { + shouldOnDrag = false + draggingTabId = id + draggingStartLocation = value.startLocation.x + draggingLastLocation = value.location.x + } + // TODO: Enable this code snippet when re-enabling dragging-out behavior. + // I disabled (1 == 0) this behavior for now as dragging-out behavior isn't allowed. + if 1 == 0 && abs(value.location.y - value.startLocation.y) > EditorTabBarView.height { + shouldOnDrag = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { + shouldOnDrag = false + draggingStartLocation = nil + draggingLastLocation = nil + draggingTabId = nil + withAnimation(.easeInOut(duration: 0.25)) { + // Clean the tab offsets. + tabOffsets = [:] + } + }) + return + } + // Get the current cursor location. + let currentLocation = value.location.x + guard let startLocation = draggingStartLocation, + let currentIndex = openedTabs.firstIndex(of: id), + let currentTabWidth = tabWidth[id], + let lastLocation = draggingLastLocation + else { return } + let dragDifference = currentLocation - lastLocation + let previousIndex = currentIndex > 0 ? currentIndex - 1 : nil + let nextIndex = currentIndex < openedTabs.count - 1 ? currentIndex + 1 : nil + tabOffsets[id] = currentLocation - startLocation + // Interacting with the previous tab. + if previousIndex != nil && dragDifference < 0 { + // Wrap `previousTabIndex` because it may be `nil`. + guard let previousTabIndex = previousIndex, + let previousTabLocation = tabLocations[openedTabs[previousTabIndex]], + let previousTabWidth = tabWidth[openedTabs[previousTabIndex]] + else { return } + if currentLocation < max( + previousTabLocation.maxX - previousTabWidth * 0.1, + previousTabLocation.minX + currentTabWidth * 0.9 + ) { + let changing = previousTabWidth - 1 // One offset for overlapping divider. + draggingStartLocation! -= changing + withAnimation { + tabOffsets[id]! += changing + openedTabs.move( + fromOffsets: IndexSet(integer: previousTabIndex), + toOffset: currentIndex + 1 + ) + } + return + } + } + // Interacting with the next tab. + if nextIndex != nil && dragDifference > 0 { + // Wrap `previousTabIndex` because it may be `nil`. + guard let nextTabIndex = nextIndex, + let nextTabLocation = tabLocations[openedTabs[nextTabIndex]], + let nextTabWidth = tabWidth[openedTabs[nextTabIndex]] + else { return } + if currentLocation > min( + nextTabLocation.minX + nextTabWidth * 0.1, + nextTabLocation.maxX - currentTabWidth * 0.9 + ) { + let changing = nextTabWidth - 1 // One offset for overlapping divider. + draggingStartLocation! += changing + withAnimation { + tabOffsets[id]! -= changing + openedTabs.move( + fromOffsets: IndexSet(integer: nextTabIndex), + toOffset: currentIndex + ) + } + return + } + } + // Only update the last dragging location when there is enough offset. + if draggingLastLocation == nil || abs(value.location.x - draggingLastLocation!) >= 10 { + draggingLastLocation = value.location.x + } + }) + .onEnded({ _ in + shouldOnDrag = false + draggingStartLocation = nil + draggingLastLocation = nil + withAnimation(.easeInOut(duration: 0.25)) { + tabOffsets = [:] + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + draggingTabId = nil + } + // Sync the workspace's `openedTabs` 150ms after animation is finished. + // In order to avoid the lag due to the update of workspace state. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) { + if draggingStartLocation == nil { + editor.tabs = .init(openedTabs.compactMap { id in + editor.tabs.first { $0.file.id == id } + }) + // workspace.reorderedTabs(openedTabs: openedTabs) + // TODO: Fix save state + } + } + }) + } +} + +// swiftlint:enable function_body_length cyclomatic_complexity diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabs.swift similarity index 53% rename from CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabs.swift index c2287c144e..7f775dbcf4 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift +++ b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabs.swift @@ -6,11 +6,9 @@ // import SwiftUI +import CodeEditCore +import CodeEditUI -// Disable the rule because the tab bar view is fairly complicated. -// It has the gesture implementation and its animations. -// I am now also disabling `file_length` rule because the dragging algorithm (with UX) is complex. -// swiftlint:disable file_length type_body_length // - TODO: EditorTabView drop-outside event handler. struct EditorTabs: View { @@ -19,15 +17,12 @@ struct EditorTabs: View { @Environment(\.colorScheme) private var colorScheme - /// The workspace document. - @EnvironmentObject private var workspace: WorkspaceDocument - - @EnvironmentObject private var editor: Editor + @EnvironmentObject var editor: Editor /// The tab id of current dragging tab. /// /// It will be `nil` when there is no tab dragged currently. - @State private var draggingTabId: TabID? + @State var draggingTabId: TabID? @State private var onDragTabId: TabID? @@ -35,38 +30,38 @@ struct EditorTabs: View { /// /// When there is no tab being dragged, it will be `nil`. /// - TODO: Check if I can use `value.startLocation` trustfully. - @State private var draggingStartLocation: CGFloat? + @State var draggingStartLocation: CGFloat? /// The last location of dragging. /// /// This is used to determine the dragging direction. /// - TODO: Check if I can use `value.translation` instead. - @State private var draggingLastLocation: CGFloat? + @State var draggingLastLocation: CGFloat? /// Current opened tabs. /// - /// This is a copy of `workspace.selectionState.openedTabs`. + /// This is a copy of `editor.tabs`. /// I am making a copy of it because using state will hugely improve the dragging performance. /// Updating ObservedObject too often will generate lags. - @State private var openedTabs: [TabID] = [] + @State var openedTabs: [TabID] = [] /// A map of tab width. /// /// All width are measured dynamically (so it can also fit the Xcode tab bar style). /// This is used to be added on the offset of current dragging tab in order to make a smooth /// dragging experience. - @State private var tabWidth: [TabID: CGFloat] = [:] + @State var tabWidth: [TabID: CGFloat] = [:] /// A map of tab location (CGRect). /// /// All locations are measured dynamically. /// This is used to compute when we should swap two tabs based on current cursor location. - @State private var tabLocations: [TabID: CGRect] = [:] + @State var tabLocations: [TabID: CGRect] = [:] /// A map of tab offsets. /// /// This is used to determine the tab offset of every tab (by their tab id) while dragging. - @State private var tabOffsets: [TabID: CGFloat] = [:] + @State var tabOffsets: [TabID: CGFloat] = [:] /// This state is used to detect if the mouse is hovering over tabs. /// If it is true, then we do not update the expected tab width immediately. @@ -74,7 +69,7 @@ struct EditorTabs: View { /// This state is used to detect if the dragging type should be changed from DragGesture to OnDrag. /// It is basically switched when vertical displacement is exceeding the threshold. - @State private var shouldOnDrag: Bool = false + @State var shouldOnDrag: Bool = false /// Is current `onDrag` over tabs? /// @@ -89,130 +84,12 @@ struct EditorTabs: View { /// It can be used on reordering algorithm of `onDrag` (detecting when should we switch two tabs). @State private var onDragLastLocation: CGPoint? - @State private var closeButtonGestureActive: Bool = false + @State var closeButtonGestureActive: Bool = false @State private var scrollOffset: CGFloat = 0 @State private var scrollTrailingOffset: CGFloat? = 0 - // Disable the rule because this function is implementing the drag gesture and its animations. - // It is fairly complicated, so ignore the function body length limitation for now. - // swiftlint:disable function_body_length cyclomatic_complexity - private func makeTabDragGesture(id: TabID) -> some Gesture { - return DragGesture(minimumDistance: 2, coordinateSpace: .global) - .onChanged({ value in - if closeButtonGestureActive { - return - } - - if draggingTabId != id { - shouldOnDrag = false - draggingTabId = id - draggingStartLocation = value.startLocation.x - draggingLastLocation = value.location.x - } - // TODO: Enable this code snippet when re-enabling dragging-out behavior. - // I disabled (1 == 0) this behavior for now as dragging-out behavior isn't allowed. - if 1 == 0 && abs(value.location.y - value.startLocation.y) > EditorTabBarView.height { - shouldOnDrag = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { - shouldOnDrag = false - draggingStartLocation = nil - draggingLastLocation = nil - draggingTabId = nil - withAnimation(.easeInOut(duration: 0.25)) { - // Clean the tab offsets. - tabOffsets = [:] - } - }) - return - } - // Get the current cursor location. - let currentLocation = value.location.x - guard let startLocation = draggingStartLocation, - let currentIndex = openedTabs.firstIndex(of: id), - let currentTabWidth = tabWidth[id], - let lastLocation = draggingLastLocation - else { return } - let dragDifference = currentLocation - lastLocation - let previousIndex = currentIndex > 0 ? currentIndex - 1 : nil - let nextIndex = currentIndex < openedTabs.count - 1 ? currentIndex + 1 : nil - tabOffsets[id] = currentLocation - startLocation - // Interacting with the previous tab. - if previousIndex != nil && dragDifference < 0 { - // Wrap `previousTabIndex` because it may be `nil`. - guard let previousTabIndex = previousIndex, - let previousTabLocation = tabLocations[openedTabs[previousTabIndex]], - let previousTabWidth = tabWidth[openedTabs[previousTabIndex]] - else { return } - if currentLocation < max( - previousTabLocation.maxX - previousTabWidth * 0.1, - previousTabLocation.minX + currentTabWidth * 0.9 - ) { - let changing = previousTabWidth - 1 // One offset for overlapping divider. - draggingStartLocation! -= changing - withAnimation { - tabOffsets[id]! += changing - openedTabs.move( - fromOffsets: IndexSet(integer: previousTabIndex), - toOffset: currentIndex + 1 - ) - } - return - } - } - // Interacting with the next tab. - if nextIndex != nil && dragDifference > 0 { - // Wrap `previousTabIndex` because it may be `nil`. - guard let nextTabIndex = nextIndex, - let nextTabLocation = tabLocations[openedTabs[nextTabIndex]], - let nextTabWidth = tabWidth[openedTabs[nextTabIndex]] - else { return } - if currentLocation > min( - nextTabLocation.minX + nextTabWidth * 0.1, - nextTabLocation.maxX - currentTabWidth * 0.9 - ) { - let changing = nextTabWidth - 1 // One offset for overlapping divider. - draggingStartLocation! += changing - withAnimation { - tabOffsets[id]! -= changing - openedTabs.move( - fromOffsets: IndexSet(integer: nextTabIndex), - toOffset: currentIndex - ) - } - return - } - } - // Only update the last dragging location when there is enough offset. - if draggingLastLocation == nil || abs(value.location.x - draggingLastLocation!) >= 10 { - draggingLastLocation = value.location.x - } - }) - .onEnded({ _ in - shouldOnDrag = false - draggingStartLocation = nil - draggingLastLocation = nil - withAnimation(.easeInOut(duration: 0.25)) { - tabOffsets = [:] - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - draggingTabId = nil - } - // Sync the workspace's `openedTabs` 150ms after animation is finished. - // In order to avoid the lag due to the update of workspace state. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) { - if draggingStartLocation == nil { - editor.tabs = .init(openedTabs.compactMap { id in - editor.tabs.first { $0.file.id == id } - }) - // workspace.reorderedTabs(openedTabs: openedTabs) - // TODO: Fix save state - } - } - }) - } - private func makeTabItemGeometryReader(id: TabID) -> some View { GeometryReader { tabItemGeoReader in Rectangle() @@ -241,8 +118,6 @@ struct EditorTabs: View { openedTabs = editor.tabs.map(\.file.id) } - // swiftlint:enable function_body_length cyclomatic_complexity - var body: some View { GeometryReader { geometryProxy in TrackableScrollView( @@ -382,63 +257,4 @@ struct EditorTabs: View { } } } - - private struct EditorTabOnDropDelegate: DropDelegate { - private let currentTabId: TabID - @Binding private var openedTabs: [TabID] - @Binding private var onDragTabId: TabID? - @Binding private var onDragLastLocation: CGPoint? - @Binding private var isOnDragOverTabs: Bool - @Binding private var tabWidth: [TabID: CGFloat] - - public init( - currentTabId: TabID, - openedTabs: Binding<[TabID]>, - onDragTabId: Binding, - onDragLastLocation: Binding, - isOnDragOverTabs: Binding, - tabWidth: Binding<[TabID: CGFloat]> - ) { - self.currentTabId = currentTabId - self._openedTabs = openedTabs - self._onDragTabId = onDragTabId - self._onDragLastLocation = onDragLastLocation - self._isOnDragOverTabs = isOnDragOverTabs - self._tabWidth = tabWidth - } - - func dropEntered(info: DropInfo) { - isOnDragOverTabs = true - guard let onDragTabId, - currentTabId != onDragTabId, - let from = openedTabs.firstIndex(of: onDragTabId), - let toIndex = openedTabs.firstIndex(of: currentTabId) - else { return } - if openedTabs[toIndex] != onDragTabId { - withAnimation { - openedTabs.move( - fromOffsets: IndexSet(integer: from), - toOffset: toIndex > from ? toIndex + 1 : toIndex - ) - } - } - } - - func dropExited(info: DropInfo) { - // Do nothing. - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - isOnDragOverTabs = false - onDragTabId = nil - onDragLastLocation = nil - return true - } - } } - -// swiftlint:enable file_length type_body_length diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabsOverflowShadow.swift b/CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabsOverflowShadow.swift similarity index 100% rename from CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabsOverflowShadow.swift rename to CodeEditModules/Sources/CEEditor/TabBar/Tabs/EditorTabsOverflowShadow.swift diff --git a/CodeEditModules/Sources/CEEditor/Theme/Theme+EditorTheme.swift b/CodeEditModules/Sources/CEEditor/Theme/Theme+EditorTheme.swift new file mode 100644 index 0000000000..e23bc851ce --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Theme/Theme+EditorTheme.swift @@ -0,0 +1,34 @@ +// +// Theme+EditorTheme.swift +// Editor +// +// Created by Matthijs Eikelenboom on 10.07.26. +// + +import CodeEditCore +import CodeEditSourceEditor +import AppKit + +public extension Theme.EditorColors { + /// Bridges the settings theme's editor colors to a source editor `EditorTheme`. + var editorTheme: EditorTheme { + .init( + text: .init(color: text.nsColor), + insertionPoint: insertionPoint.nsColor, + invisibles: .init(color: invisibles.nsColor), + background: background.nsColor, + lineHighlight: lineHighlight.nsColor, + selection: selection.nsColor, + keywords: .init(color: keywords.nsColor), + commands: .init(color: commands.nsColor), + types: .init(color: types.nsColor), + attributes: .init(color: attributes.nsColor), + variables: .init(color: variables.nsColor), + values: .init(color: values.nsColor), + numbers: .init(color: numbers.nsColor), + strings: .init(color: strings.nsColor), + characters: .init(color: characters.nsColor), + comments: .init(color: comments.nsColor) + ) + } +} diff --git a/CodeEditModules/Sources/CEEditor/Theme/Theme+NSColor.swift b/CodeEditModules/Sources/CEEditor/Theme/Theme+NSColor.swift new file mode 100644 index 0000000000..2ddbe43d1f --- /dev/null +++ b/CodeEditModules/Sources/CEEditor/Theme/Theme+NSColor.swift @@ -0,0 +1,27 @@ +// +// Theme+NSColor.swift +// CEEditor +// +// Created by Matthijs Eikelenboom on 15/08/2026. +// + +import AppKit +import CodeEditCore +import CodeEditUI + +extension Theme.Attributes { + /// The attribute's color as an AppKit `NSColor`; setting it stores the new value as a hex string. + /// + /// Deliberately module-private: `Theme` lives in `CodeEditCore`, which may not import AppKit, and + /// `CodeEditUI` — which owns the hex conversion — may not import `CodeEditCore`. Each module that + /// needs a typed color keeps its own adapter over the shared `String`-keyed helper. `CEEditor` is + /// the only module that reads theme colors as `NSColor`, so this duplicates nothing. + var nsColor: NSColor { + get { + NSColor(hex: color) + } + set { + self.color = newValue.hexString + } + } +} diff --git a/CodeEditModules/Sources/CELSP/CodeFileDocument+LanguageServerDocument.swift b/CodeEditModules/Sources/CELSP/CodeFileDocument+LanguageServerDocument.swift new file mode 100644 index 0000000000..baef27c4ac --- /dev/null +++ b/CodeEditModules/Sources/CELSP/CodeFileDocument+LanguageServerDocument.swift @@ -0,0 +1,17 @@ +// +// CodeFileDocument+LanguageServerDocument.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import AppKit +import CodeEditDocument + +extension CodeFileDocument: @preconcurrency LanguageServerDocument { + /// A stable string to use when identifying documents with language servers. + /// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI. + public var languageServerURI: String? { + fileURL?.lspURI + } +} diff --git a/CodeEdit/Utils/Extensions/LanguageIdentifier/LanguageIdentifier+CodeLanguage.swift b/CodeEditModules/Sources/CELSP/Conversions/LanguageIdentifier+CodeLanguage.swift similarity index 100% rename from CodeEdit/Utils/Extensions/LanguageIdentifier/LanguageIdentifier+CodeLanguage.swift rename to CodeEditModules/Sources/CELSP/Conversions/LanguageIdentifier+CodeLanguage.swift diff --git a/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift b/CodeEditModules/Sources/CELSP/Conversions/TextView+LSPRange.swift similarity index 100% rename from CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift rename to CodeEditModules/Sources/CELSP/Conversions/TextView+LSPRange.swift diff --git a/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift b/CodeEditModules/Sources/CELSP/Conversions/URL+LSPURI.swift similarity index 100% rename from CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift rename to CodeEditModules/Sources/CELSP/Conversions/URL+LSPURI.swift diff --git a/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift b/CodeEditModules/Sources/CELSP/Features/DocumentSync/LSPContentCoordinator.swift similarity index 83% rename from CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift rename to CodeEditModules/Sources/CELSP/Features/DocumentSync/LSPContentCoordinator.swift index b2c2e75b7c..1263c74793 100644 --- a/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift +++ b/CodeEditModules/Sources/CELSP/Features/DocumentSync/LSPContentCoordinator.swift @@ -6,6 +6,7 @@ // import AppKit +import CodeEditDocument import AsyncAlgorithms import CodeEditSourceEditor import CodeEditTextView @@ -19,7 +20,10 @@ import LanguageServerProtocol /// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class /// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then /// chunked into 250ms timed groups before being sent to the ``LanguageServer``. -class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { +@MainActor +class LSPContentCoordinator< + DocumentType: LanguageServerDocument +>: @preconcurrency TextViewCoordinator, @preconcurrency TextViewDelegate { // Required to avoid a large_tuple lint error private struct SequenceElement: Sendable { let uri: String @@ -28,10 +32,13 @@ class LSPContentCoordinator: TextViewCoord } private var editedRange: LSPRange? - private var sequenceContinuation: AsyncStream.Continuation? - private var task: Task? + // nonisolated(unsafe): assigned on the main actor during setup; read from the + // detached debounce task (`languageServer`, `sequenceContinuation`) and from + // `deinit` (`task`, `sequenceContinuation`), which cannot be actor-isolated. + nonisolated(unsafe) private var sequenceContinuation: AsyncStream.Continuation? + nonisolated(unsafe) private var task: Task? - weak var languageServer: LanguageServer? + nonisolated(unsafe) weak var languageServer: LanguageServer? var documentURI: String? /// Initializes a content coordinator, and begins an async stream of updates @@ -88,7 +95,7 @@ class LSPContentCoordinator: TextViewCoord self.sequenceContinuation?.yield(SequenceElement(uri: documentURI, range: lspRange, string: string)) } - func destroy() { + nonisolated func destroy() { task?.cancel() task = nil sequenceContinuation?.finish() diff --git a/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticToken+Position.swift similarity index 100% rename from CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticToken+Position.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift similarity index 98% rename from CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift index 2fbfb8ea8a..81f666721f 100644 --- a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift +++ b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift @@ -7,7 +7,7 @@ import Foundation import LanguageServerProtocol -import CodeEditSourceEditor +@preconcurrency import CodeEditSourceEditor import CodeEditTextView import CodeEditLanguages @@ -21,10 +21,11 @@ import CodeEditLanguages /// ``SemanticTokenHighlightProvider/applyEdit(textView:range:delta:completion:)`` method. One might expect this class /// to respond to that method immediately, but it does not. It instead stores the completion passed in that method until /// it can respond to the edit with invalidated indices. +@MainActor final class SemanticTokenHighlightProvider< Storage: GenericSemanticTokenStorage, DocumentType: LanguageServerDocument ->: HighlightProviding { +>: @preconcurrency HighlightProviding { enum HighlightError: Error { case lspRangeFailure } diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenMap.swift similarity index 100% rename from CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenMap.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift similarity index 100% rename from CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift similarity index 100% rename from CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift similarity index 100% rename from CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift similarity index 100% rename from CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift diff --git a/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift b/CodeEditModules/Sources/CELSP/Features/SemanticTokens/TextView+SemanticTokenRangeProvider.swift similarity index 100% rename from CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift rename to CodeEditModules/Sources/CELSP/Features/SemanticTokens/TextView+SemanticTokenRangeProvider.swift diff --git a/CodeEdit/Features/LSP/LSPUtil.swift b/CodeEditModules/Sources/CELSP/LSPCompletionItemsUtil.swift similarity index 98% rename from CodeEdit/Features/LSP/LSPUtil.swift rename to CodeEditModules/Sources/CELSP/LSPCompletionItemsUtil.swift index 740a821041..d1bb89b4ee 100644 --- a/CodeEdit/Features/LSP/LSPUtil.swift +++ b/CodeEditModules/Sources/CELSP/LSPCompletionItemsUtil.swift @@ -1,5 +1,5 @@ // -// LSPUtil.swift +// LSPCompletionItemsUtil.swift // CodeEdit // // Created by Abe Malla on 2/10/24. diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Completion.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Completion.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Completion.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Completion.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Definition.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Definition.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Definition.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Definition.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift similarity index 92% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index 2c30e6935f..5ca5843630 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -108,7 +108,7 @@ extension LanguageServer { // Let the semantic token provider know about the update. // Note for future: If a related LSP object need notifying about document changes, do it here. - try await document.languageServerObjects.highlightProvider.documentDidChange() + try await notifyHighlightProviderDidChange(document) } catch { logger.warning("closeDocument: Error \(error)") throw error @@ -129,12 +129,21 @@ extension LanguageServer { @MainActor private func updateIsolatedDocument(_ document: DocumentType) { - document.languageServerObjects.setUp(server: self, document: document) + provideObjects(document).setUp(server: self, document: document) } @MainActor private func clearIsolatedDocument(_ document: DocumentType) { - document.languageServerObjects = LanguageServerDocumentObjects() + if let uri = document.languageServerURI { + clearObjects(uri) + } + } + + /// Notifies the document's highlight provider of a change. Kept `@MainActor` so the + /// non-`Sendable` `LanguageServerDocumentObjects` never crosses an actor boundary. + @MainActor + private func notifyHighlightProviderDidChange(_ document: DocumentType) async throws { + try await provideObjects(document).highlightProvider.documentDidChange() } // swiftlint:disable line_length diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Hover.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Hover.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Hover.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Hover.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+References.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+References.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+References.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+References.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Rename.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Rename.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Rename.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+Rename.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift b/CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/LSPCache+Data.swift b/CodeEditModules/Sources/CELSP/LanguageServer/LSPCache+Data.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/LSPCache+Data.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/LSPCache+Data.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/LSPCache.swift b/CodeEditModules/Sources/CELSP/LanguageServer/LSPCache.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/LSPCache.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/LSPCache.swift diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEditModules/Sources/CELSP/LanguageServer/LanguageServer.swift similarity index 85% rename from CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/LanguageServer.swift index 7f855bbf01..9ee535d9fe 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEditModules/Sources/CELSP/LanguageServer/LanguageServer.swift @@ -12,14 +12,18 @@ import LanguageServerProtocol import OSLog /// A client for language servers. -class LanguageServer { - static var logger: Logger { // types with associated types cannot have constant static properties +/// Main-actor isolated: per-document work touches main-actor documents and editor +/// objects; network calls hop to the connection internally and pass only Sendable +/// LSP payloads. +@MainActor +public class LanguageServer { + nonisolated static var logger: Logger { // types with associated types cannot have constant static properties Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer") } let logger: Logger /// Identifies which language the server belongs to - let languageId: LanguageIdentifier + public let languageId: LanguageIdentifier /// Holds information about the language server binary let binary: LanguageServerBinary /// A cache to hold responses from the server, to minimize duplicate server requests @@ -30,13 +34,20 @@ class LanguageServer { /// language server and a document. For example, the content coordinator. let openFiles: LanguageServerFileMap + /// Resolves the per-document LSP objects (owned by `LSPService`). Injected at creation so the + /// document itself need not store them. + let provideObjects: @MainActor (DocumentType) -> LanguageServerDocumentObjects + /// Drops the per-document LSP objects for a URI when a document closes. + let clearObjects: @MainActor (String) -> Void + /// Maps the language server's highlight config to one CodeEdit can read. See ``SemanticTokenMap``. let highlightMap: SemanticTokenMap? /// The configuration options this server supports. var serverCapabilities: ServerCapabilities - var logContainer: LanguageServerLogContainer + /// Buffers log messages received from the server for display in the language server UI. + public var logContainer: LanguageServerLogContainer /// An instance of a language server, that may or may not be initialized private(set) var lspInstance: InitializingServer @@ -52,8 +63,13 @@ class LanguageServer { lspPid: pid_t, serverCapabilities: ServerCapabilities, rootPath: URL, - logContainer: LanguageServerLogContainer + logContainer: LanguageServerLogContainer, + provideObjects: @escaping @MainActor (DocumentType) -> LanguageServerDocumentObjects + = { _ in LanguageServerDocumentObjects() }, + clearObjects: @escaping @MainActor (String) -> Void = { _ in } ) { + self.provideObjects = provideObjects + self.clearObjects = clearObjects self.languageId = languageId self.binary = binary self.lspInstance = lspInstance @@ -82,7 +98,10 @@ class LanguageServer { static func createServer( for languageId: LanguageIdentifier, with binary: LanguageServerBinary, - workspacePath: String + workspacePath: String, + provideObjects: @escaping @MainActor (DocumentType) -> LanguageServerDocumentObjects + = { _ in LanguageServerDocumentObjects() }, + clearObjects: @escaping @MainActor (String) -> Void = { _ in } ) async throws -> LanguageServer { let executionParams = Process.ExecutionParameters( path: binary.execPath, @@ -109,7 +128,9 @@ class LanguageServer { lspPid: process.processIdentifier, serverCapabilities: initializationResponse.capabilities, rootPath: URL(filePath: workspacePath), - logContainer: logContainer + logContainer: logContainer, + provideObjects: provideObjects, + clearObjects: clearObjects ) } @@ -120,7 +141,7 @@ class LanguageServer { /// - languageId: The ID of the language to create the channel for. /// - executionParams: The parameters for executing the local process. /// - Returns: A new connection to the language server. - static func makeLocalServerConnection( + nonisolated static func makeLocalServerConnection( languageId: LanguageIdentifier, executionParams: Process.ExecutionParameters, logContainer: LanguageServerLogContainer @@ -145,7 +166,7 @@ class LanguageServer { // MARK: - Get Init Params // swiftlint:disable function_body_length - static func getInitParams(workspacePath: String) -> InitializingServer.InitializeParamsProvider { + nonisolated static func getInitParams(workspacePath: String) -> InitializingServer.InitializeParamsProvider { let provider: InitializingServer.InitializeParamsProvider = { // Text Document Capabilities let textDocumentCapabilities = TextDocumentClientCapabilities( diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEditModules/Sources/CELSP/LanguageServer/LanguageServerFileMap.swift similarity index 100% rename from CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift rename to CodeEditModules/Sources/CELSP/LanguageServer/LanguageServerFileMap.swift diff --git a/CodeEdit/Features/LSP/LanguageServerDocument.swift b/CodeEditModules/Sources/CELSP/LanguageServerDocument.swift similarity index 63% rename from CodeEdit/Features/LSP/LanguageServerDocument.swift rename to CodeEditModules/Sources/CELSP/LanguageServerDocument.swift index 2953d08fc2..80e66f71b8 100644 --- a/CodeEdit/Features/LSP/LanguageServerDocument.swift +++ b/CodeEditModules/Sources/CELSP/LanguageServerDocument.swift @@ -9,22 +9,27 @@ import AppKit import CodeEditLanguages /// A set of properties a language server sets when a document is registered. +/// Main-actor isolated: its members are editor-facing (text coordinator, highlight +/// provider) and are created/used from the main actor by `LSPService`. +@MainActor struct LanguageServerDocumentObjects { var textCoordinator: LSPContentCoordinator = LSPContentCoordinator() // swiftlint:disable:next line_length var highlightProvider: SemanticTokenHighlightProvider = SemanticTokenHighlightProvider() - @MainActor func setUp(server: LanguageServer, document: DocumentType) { textCoordinator.setUp(server: server, document: document) highlightProvider.setUp(server: server, document: document) } } -/// A protocol that allows a language server to register objects on a text document. -protocol LanguageServerDocument: AnyObject { +/// A protocol that allows a language server to work with a text document. +/// +/// Deliberately holds no LSP-typed state: the per-document objects +/// (``LanguageServerDocumentObjects``) are owned by `LSPService`, keyed by URI, so conformers +/// (e.g. `CodeFileDocument`) need not depend on LSP types. +public protocol LanguageServerDocument: AnyObject { var content: NSTextStorage? { get } var languageServerURI: String? { get } - var languageServerObjects: LanguageServerDocumentObjects { get set } func getLanguage() -> CodeLanguage } diff --git a/CodeEditModules/Sources/CELSP/LanguageServerSettings.swift b/CodeEditModules/Sources/CELSP/LanguageServerSettings.swift new file mode 100644 index 0000000000..a6bb5fcb88 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/LanguageServerSettings.swift @@ -0,0 +1,40 @@ +// +// LanguageServerSettings.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import CodeEditSettings +import Foundation + +public struct LanguageServerSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "languageServers" + + /// Stores the currently installed language servers. The key is the name of the language server. + @CodableDefault public var installedLanguageServers: + [String: Installed] = [:] + + /// Default initializer + public init() {} + + public struct Installed: Codable, Hashable { + public let packageName: String + public var isEnabled: Bool + public let version: String + + public init(packageName: String, isEnabled: Bool, version: String) { + self.packageName = packageName + self.isEnabled = isEnabled + self.version = version + } + } +} + +// MARK: - Defaults + +public enum DefaultEmptyLanguageServerDictionary: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue: [String: LanguageServerSettings.Installed] = [:] +} diff --git a/CodeEdit/Features/LSP/Registry/Errors/PackageManagerError.swift b/CodeEditModules/Sources/CELSP/Registry/Errors/PackageManagerError.swift similarity index 100% rename from CodeEdit/Features/LSP/Registry/Errors/PackageManagerError.swift rename to CodeEditModules/Sources/CELSP/Registry/Errors/PackageManagerError.swift diff --git a/CodeEdit/Features/LSP/Registry/Errors/RegistryManagerError.swift b/CodeEditModules/Sources/CELSP/Registry/Errors/RegistryManagerError.swift similarity index 90% rename from CodeEdit/Features/LSP/Registry/Errors/RegistryManagerError.swift rename to CodeEditModules/Sources/CELSP/Registry/Errors/RegistryManagerError.swift index 68c1006e4e..44a8df6453 100644 --- a/CodeEdit/Features/LSP/Registry/Errors/RegistryManagerError.swift +++ b/CodeEditModules/Sources/CELSP/Registry/Errors/RegistryManagerError.swift @@ -7,7 +7,7 @@ import Foundation -enum RegistryManagerError: Error, LocalizedError { +public enum RegistryManagerError: Error, LocalizedError { case installationRunning case invalidResponse(statusCode: Int) case downloadFailed(url: URL, error: Error) @@ -15,7 +15,7 @@ enum RegistryManagerError: Error, LocalizedError { case writeFailed(error: Error) case failedToSaveRegistryCache - var errorDescription: String? { + public var errorDescription: String? { switch self { case .installationRunning: "A package is already being installed." @@ -32,7 +32,7 @@ enum RegistryManagerError: Error, LocalizedError { } } - var failureReason: String? { + public var failureReason: String? { switch self { case .installationRunning, .invalidResponse, .failedToSaveRegistryCache: return nil diff --git a/CodeEditModules/Sources/CELSP/Registry/InstallationMethod+PackageManager.swift b/CodeEditModules/Sources/CELSP/Registry/InstallationMethod+PackageManager.swift new file mode 100644 index 0000000000..b130a6f42d --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Registry/InstallationMethod+PackageManager.swift @@ -0,0 +1,31 @@ +// +// InstallationMethod+PackageManager.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/04/26. +// + +import Foundation +import CodeEditCore + +extension InstallationMethod { + func packageManager(installPath: URL, shellClient: ShellClientProtocol) -> PackageManagerProtocol? { + switch packageManagerType { + case .npm: + return NPMPackageManager(installationDirectory: installPath, shellClient: shellClient) + case .cargo: + return CargoPackageManager(installationDirectory: installPath, shellClient: shellClient) + case .pip: + return PipPackageManager(installationDirectory: installPath, shellClient: shellClient) + case .golang: + return GolangPackageManager(installationDirectory: installPath, shellClient: shellClient) + case .github, .sourceBuild: + return GithubPackageManager(installationDirectory: installPath, shellClient: shellClient) + case .nuget, .opam, .gem, .composer: + // TODO: IMPLEMENT OTHER PACKAGE MANAGERS + return nil + default: + return nil + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/Model/InstallationMethod.swift b/CodeEditModules/Sources/CELSP/Registry/InstallationMethod.swift similarity index 64% rename from CodeEdit/Features/LSP/Registry/Model/InstallationMethod.swift rename to CodeEditModules/Sources/CELSP/Registry/InstallationMethod.swift index 87c5c0d2bd..56b8f6852c 100644 --- a/CodeEdit/Features/LSP/Registry/Model/InstallationMethod.swift +++ b/CodeEditModules/Sources/CELSP/Registry/InstallationMethod.swift @@ -8,7 +8,7 @@ import Foundation /// Installation method enum with all supported types -enum InstallationMethod: Equatable { +public enum InstallationMethod: Equatable { /// For standard package manager installations case standardPackage(source: PackageSource) /// For packages that need to be built from source with custom build steps @@ -18,7 +18,7 @@ enum InstallationMethod: Equatable { /// For installations that aren't recognized case unknown - var packageName: String? { + public var packageName: String? { switch self { case .standardPackage(let source), .sourceBuild(let source, _), @@ -29,7 +29,7 @@ enum InstallationMethod: Equatable { } } - var version: String? { + public var version: String? { switch self { case .standardPackage(let source), .sourceBuild(let source, _), @@ -40,7 +40,7 @@ enum InstallationMethod: Equatable { } } - var packageManagerType: PackageManagerType? { + public var packageManagerType: PackageManagerType? { switch self { case .standardPackage(let source), .sourceBuild(let source, _), @@ -51,27 +51,7 @@ enum InstallationMethod: Equatable { } } - func packageManager(installPath: URL) -> PackageManagerProtocol? { - switch packageManagerType { - case .npm: - return NPMPackageManager(installationDirectory: installPath) - case .cargo: - return CargoPackageManager(installationDirectory: installPath) - case .pip: - return PipPackageManager(installationDirectory: installPath) - case .golang: - return GolangPackageManager(installationDirectory: installPath) - case .github, .sourceBuild: - return GithubPackageManager(installationDirectory: installPath) - case .nuget, .opam, .gem, .composer: - // TODO: IMPLEMENT OTHER PACKAGE MANAGERS - return nil - default: - return nil - } - } - - var installerDescription: String { + public var installerDescription: String { guard let packageManagerType else { return "Unknown" } switch packageManagerType { case .npm, .cargo, .golang, .pip, .sourceBuild, .github: @@ -81,7 +61,7 @@ enum InstallationMethod: Equatable { } } - var packageDescription: String? { + public var packageDescription: String? { guard let packageName else { return nil } if let version { return "\(packageName)@\(version)" diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagerProtocol.swift similarity index 86% rename from CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagerProtocol.swift index c7398e0edc..d532712eea 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagerProtocol.swift @@ -1,15 +1,16 @@ // -// PackageManager.swift +// PackageManagerProtocol.swift // CodeEdit // // Created by Abe Malla on 2/2/25. // import Foundation +import CodeEditCore /// The protocol each package manager conforms to for creating ``PackageManagerInstallOperation``s. protocol PackageManagerProtocol { - var shellClient: ShellClient { get } + var shellClient: ShellClientProtocol { get } /// Calls the shell commands to install a package func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] diff --git a/CodeEdit/Features/LSP/Registry/Model/PackageManagerType.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagerType.swift similarity index 91% rename from CodeEdit/Features/LSP/Registry/Model/PackageManagerType.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagerType.swift index 28ebacee34..2a3d8ee859 100644 --- a/CodeEdit/Features/LSP/Registry/Model/PackageManagerType.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagerType.swift @@ -6,7 +6,7 @@ // /// Package manager types supported by the system -enum PackageManagerType: String, Codable { +public enum PackageManagerType: String, Codable { /// JavaScript case npm /// Rust @@ -28,7 +28,7 @@ enum PackageManagerType: String, Codable { /// Binary download case github - var userDescription: String { + public var userDescription: String { switch self { case .npm: "NPM" diff --git a/CodeEdit/Utils/Extensions/FileManager/FileManager+MakeExecutable.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/FileManager+MakeExecutable.swift similarity index 100% rename from CodeEdit/Utils/Extensions/FileManager/FileManager+MakeExecutable.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/FileManager+MakeExecutable.swift diff --git a/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift new file mode 100644 index 0000000000..6404a939d8 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift @@ -0,0 +1,14 @@ +// +// InstallStepConfirmation.swift +// CodeEdit +// +// Created by Khan Winter on 8/8/25. +// + +/// Whether a package installation step requires the user's confirmation before it executes. +public enum InstallStepConfirmation { + /// No confirmation is needed; the step can run immediately. + case none + /// The user must approve the step before it runs; `message` describes what will happen. + case required(message: String) +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift similarity index 61% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift index 03e866adbb..7007da503d 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift @@ -7,6 +7,7 @@ import Foundation import Combine +import CodeEditCore /// An executable install operation for installing a ``RegistryItem``. /// @@ -15,38 +16,38 @@ import Combine /// /// If a step requires confirmation, the ``waitingForConfirmation`` value will be filled. @MainActor -final class PackageManagerInstallOperation: ObservableObject, Identifiable { - enum RunningState { +public final class PackageManagerInstallOperation: ObservableObject, Identifiable { + public enum RunningState { case none case running case complete } - struct OutputItem: Identifiable, Equatable { - let id: UUID = UUID() - let isStepDivider: Bool - let outputIdx: Int? - let contents: String + public struct OutputItem: Identifiable, Equatable { + public let id: UUID = UUID() + public let isStepDivider: Bool + public let outputIdx: Int? + public let contents: String - init(outputIdx: Int? = nil, isStepDivider: Bool = false, contents: String) { + public init(outputIdx: Int? = nil, isStepDivider: Bool = false, contents: String) { self.isStepDivider = isStepDivider self.outputIdx = outputIdx self.contents = contents } } - nonisolated var id: String { package.name } + nonisolated public var id: String { package.name } - let package: RegistryItem - let steps: [PackageManagerInstallStep] + public let package: RegistryItem + public let steps: [PackageManagerInstallStep] /// The step the operation is currently executing or stopped at. - var currentStep: PackageManagerInstallStep? { + public var currentStep: PackageManagerInstallStep? { steps[safe: currentStepIdx] } /// The current state of the operation. - var runningState: RunningState { + public var runningState: RunningState { if operationTask != nil { return .running } else if error != nil || currentStepIdx == steps.count { @@ -56,15 +57,15 @@ final class PackageManagerInstallOperation: ObservableObject, Identifiable { } } - @Published var accumulatedOutput: [OutputItem] = [] - @Published var currentStepIdx: Int = 0 - @Published var error: Error? - @Published var progress: Progress + @Published public var accumulatedOutput: [OutputItem] = [] + @Published public var currentStepIdx: Int = 0 + @Published public var error: Error? + @Published public var progress: Progress /// If non-nil, indicates that this operation has halted and requires confirmation. @Published public private(set) var waitingForConfirmation: String? - private let shellClient: ShellClient = .live() + private let shellClient: ShellClientProtocol private var operationTask: Task? private var confirmationContinuation: CheckedContinuation? private var outputIdx = 0 @@ -74,13 +75,14 @@ final class PackageManagerInstallOperation: ObservableObject, Identifiable { /// - Parameters: /// - package: The package to install. /// - steps: The steps that make up the operation. - init(package: RegistryItem, steps: [PackageManagerInstallStep]) { + public init(package: RegistryItem, steps: [PackageManagerInstallStep], shellClient: ShellClientProtocol) { + self.shellClient = shellClient self.package = package self.steps = steps self.progress = Progress(totalUnitCount: Int64(steps.count)) } - func run() async throws { + public func run() async throws { guard operationTask == nil else { return } operationTask = Task { defer { operationTask = nil } @@ -89,13 +91,13 @@ final class PackageManagerInstallOperation: ObservableObject, Identifiable { try await operationTask?.value } - func cancel() { + public func cancel() { operationTask?.cancel() operationTask = nil } /// Called by UI to confirm continuing to the next step - func confirmCurrentStep() { + public func confirmCurrentStep() { waitingForConfirmation = nil confirmationContinuation?.resume() confirmationContinuation = nil @@ -128,34 +130,30 @@ final class PackageManagerInstallOperation: ObservableObject, Identifiable { try Task.checkCancellation() accumulatedOutput.append(OutputItem(isStepDivider: true, contents: "Step \(currentStepIdx + 1): \(task.name)")) - await withTaskGroup(of: Void.self) { group in - group.addTask { - for await outputItem in model.outputStream { - await MainActor.run { - switch outputItem { - case .status(let string): - self.outputIdx += 1 - self.accumulatedOutput.append(OutputItem(outputIdx: self.outputIdx, contents: string)) - case .output(let string): - self.accumulatedOutput.append(OutputItem(contents: string)) - } - } - } - } - group.addTask { - do { - try await task.handler(model) - } catch { - await MainActor.run { - self.error = error - } - } - await MainActor.run { - model.finish() + // `Task {}` inherits this method's main-actor isolation, so the capture of `model` + // and `self` stays in-region (a task group's `addTask` requires `sending` closures, + // which the non-Sendable progress model can't satisfy). + let outputForwarding = Task { + for await outputItem in model.outputStream { + switch outputItem { + case .status(let string): + self.outputIdx += 1 + self.accumulatedOutput.append(OutputItem(outputIdx: self.outputIdx, contents: string)) + case .output(let string): + self.accumulatedOutput.append(OutputItem(contents: string)) } } } + do { + try await task.handler(model) + } catch { + self.error = error + } + model.finish() + // Drain the stream so all output lands before the next step begins. + await outputForwarding.value + self.currentStepIdx += 1 try Task.checkCancellation() diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift similarity index 58% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift index 7c57106462..ce0c9a6c15 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift @@ -6,9 +6,9 @@ // /// Represents a single executable step in a package install. -struct PackageManagerInstallStep: Identifiable { - var id: String { name } - let name: String - let confirmation: InstallStepConfirmation +public struct PackageManagerInstallStep: Identifiable { + public var id: String { name } + public let name: String + public let confirmation: InstallStepConfirmation let handler: (_ model: PackageManagerProgressModel) async throws -> Void } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift similarity index 95% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift index 7451ef22e8..787b107a0e 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift @@ -5,8 +5,9 @@ // Created by Khan Winter on 8/8/25. // -import Foundation import Combine +import Foundation +import CodeEditCore /// This model is injected into each ``PackageManagerInstallStep`` when executing a ``PackageManagerInstallOperation``. /// A single model is used for each step. Output is collected by the ``PackageManagerInstallOperation``. @@ -23,10 +24,10 @@ final class PackageManagerProgressModel: ObservableObject { let outputStream: AsyncStream @Published var progress: Progress - private let shellClient: ShellClient + private let shellClient: ShellClientProtocol private let outputContinuation: AsyncStream.Continuation - init(shellClient: ShellClient) { + init(shellClient: ShellClientProtocol) { self.shellClient = shellClient self.progress = Progress(totalUnitCount: 1) (outputStream, outputContinuation) = AsyncStream.makeStream() diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/CargoPackageManager.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/CargoPackageManager.swift similarity index 94% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Sources/CargoPackageManager.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/CargoPackageManager.swift index 8eef86c149..2f5a3086a0 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/CargoPackageManager.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/CargoPackageManager.swift @@ -6,15 +6,16 @@ // import Foundation +import CodeEditCore final class CargoPackageManager: PackageManagerProtocol { private let installationDirectory: URL - let shellClient: ShellClient + let shellClient: ShellClientProtocol - init(installationDirectory: URL) { + init(installationDirectory: URL, shellClient: ShellClientProtocol) { self.installationDirectory = installationDirectory - self.shellClient = .live() + self.shellClient = shellClient } func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GithubPackageManager.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/GithubPackageManager.swift similarity index 98% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GithubPackageManager.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/GithubPackageManager.swift index de06433c1d..30cd2a93df 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GithubPackageManager.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/GithubPackageManager.swift @@ -6,15 +6,16 @@ // import Foundation +import CodeEditCore final class GithubPackageManager: PackageManagerProtocol { private let installationDirectory: URL - let shellClient: ShellClient + let shellClient: ShellClientProtocol - init(installationDirectory: URL) { + init(installationDirectory: URL, shellClient: ShellClientProtocol) { self.installationDirectory = installationDirectory - self.shellClient = .live() + self.shellClient = shellClient } // MARK: - PackageManagerProtocol diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GolangPackageManager.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/GolangPackageManager.swift similarity index 97% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GolangPackageManager.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/GolangPackageManager.swift index 574cdd2e39..2259e789ad 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GolangPackageManager.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/GolangPackageManager.swift @@ -6,15 +6,16 @@ // import Foundation +import CodeEditCore final class GolangPackageManager: PackageManagerProtocol { private let installationDirectory: URL - let shellClient: ShellClient + let shellClient: ShellClientProtocol - init(installationDirectory: URL) { + init(installationDirectory: URL, shellClient: ShellClientProtocol) { self.installationDirectory = installationDirectory - self.shellClient = .live() + self.shellClient = shellClient } // MARK: - PackageManagerProtocol diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/NPMPackageManager.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/NPMPackageManager.swift similarity index 97% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Sources/NPMPackageManager.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/NPMPackageManager.swift index e5988429b3..f299f18cc1 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/NPMPackageManager.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/NPMPackageManager.swift @@ -6,15 +6,16 @@ // import Foundation +import CodeEditCore final class NPMPackageManager: PackageManagerProtocol { private let installationDirectory: URL - let shellClient: ShellClient + let shellClient: ShellClientProtocol - init(installationDirectory: URL) { + init(installationDirectory: URL, shellClient: ShellClientProtocol) { self.installationDirectory = installationDirectory - self.shellClient = .live() + self.shellClient = shellClient } // MARK: - PackageManagerProtocol diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/PipPackageManager.swift b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/PipPackageManager.swift similarity index 97% rename from CodeEdit/Features/LSP/Registry/PackageManagers/Sources/PipPackageManager.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/PipPackageManager.swift index b7840e46aa..63b42dde79 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/PipPackageManager.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageManagers/Sources/PipPackageManager.swift @@ -6,15 +6,16 @@ // import Foundation +import CodeEditCore final class PipPackageManager: PackageManagerProtocol { private let installationDirectory: URL - let shellClient: ShellClient + let shellClient: ShellClientProtocol - init(installationDirectory: URL) { + init(installationDirectory: URL, shellClient: ShellClientProtocol) { self.installationDirectory = installationDirectory - self.shellClient = .live() + self.shellClient = shellClient } // MARK: - PackageManagerProtocol diff --git a/CodeEdit/Features/LSP/Registry/Model/PackageSource.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSource.swift similarity index 74% rename from CodeEdit/Features/LSP/Registry/Model/PackageSource.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSource.swift index 0df959fb80..b2a0b4edd6 100644 --- a/CodeEdit/Features/LSP/Registry/Model/PackageSource.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSource.swift @@ -7,25 +7,25 @@ /// Generic package source information that applies to all installation methods. /// Takes all the necessary information from `RegistryItem`. -struct PackageSource: Equatable, Codable { +public struct PackageSource: Equatable, Codable { /// The raw source ID string from the registry - let sourceId: String + public let sourceId: String /// The type of the package manager - let type: PackageManagerType + public let type: PackageManagerType /// Package name - let pkgName: String + public let pkgName: String /// The name in the registry.json file. Used for the folder name when saved. - let entryName: String + public let entryName: String /// Package version - let version: String + public let version: String /// URL for repository or download link - let repositoryUrl: String? + public let repositoryUrl: String? /// Git reference type if this is a git based package - let gitReference: GitReference? + public let gitReference: GitReference? /// Additional possible options - var options: [String: String] + public var options: [String: String] - init( + public init( sourceId: String, type: PackageManagerType, pkgName: String, @@ -45,7 +45,7 @@ struct PackageSource: Equatable, Codable { self.options = options } - enum GitReference: Equatable, Codable { + public enum GitReference: Equatable, Codable { case tag(String) case revision(String) } diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift index 0b81d6a97a..aa2b72c8b3 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -5,6 +5,8 @@ // Created by Abe Malla on 3/12/25. // +import CodeEditCore + extension PackageSourceParser { static func parseCargoPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:cargo/PACKAGE@VERSION?PARAMS diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift index 1c2c7734af..82877e9003 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -5,6 +5,8 @@ // Created by Abe Malla on 3/12/25. // +import CodeEditCore + extension PackageSourceParser { static func parseRubyGem(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:gem/PACKAGE@VERSION?PARAMS diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift index d75bf49700..99a8beb249 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -5,6 +5,8 @@ // Created by Abe Malla on 3/12/25. // +import CodeEditCore + extension PackageSourceParser { static func parseGolangPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift index b5a63bb9e9..398b87c449 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -5,6 +5,8 @@ // Created by Abe Malla on 3/12/25. // +import CodeEditCore + extension PackageSourceParser { static func parseNpmPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:npm/PACKAGE@VERSION?PARAMS diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift index bb41d7dc55..d7ec7ec0c7 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift @@ -5,6 +5,8 @@ // Created by Abe Malla on 3/12/25. // +import CodeEditCore + extension PackageSourceParser { static func parsePythonPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:pypi/PACKAGE@VERSION?PARAMS diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift rename to CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser.swift index 803d061fa1..5c9910fa5d 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift +++ b/CodeEditModules/Sources/CELSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditCore /// Parser for package source IDs enum PackageSourceParser { diff --git a/CodeEditModules/Sources/CELSP/Registry/RegistryItem+InstallMethod.swift b/CodeEditModules/Sources/CELSP/Registry/RegistryItem+InstallMethod.swift new file mode 100644 index 0000000000..d7ba2fe5a5 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Registry/RegistryItem+InstallMethod.swift @@ -0,0 +1,31 @@ +// +// RegistryItem+InstallMethod.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/04/26. +// + +import Foundation +import CodeEditCore + +extension RegistryItem { + /// The method for installation, parsed from this item's ``source`` parameter. + public var installMethod: InstallationMethod? { + let sourceId = source.id + if sourceId.hasPrefix("pkg:cargo/") { + return PackageSourceParser.parseCargoPackage(self) + } else if sourceId.hasPrefix("pkg:npm/") { + return PackageSourceParser.parseNpmPackage(self) + } else if sourceId.hasPrefix("pkg:pypi/") { + return PackageSourceParser.parsePythonPackage(self) + } else if sourceId.hasPrefix("pkg:gem/") { + return PackageSourceParser.parseRubyGem(self) + } else if sourceId.hasPrefix("pkg:golang/") { + return PackageSourceParser.parseGolangPackage(self) + } else if sourceId.hasPrefix("pkg:github/") { + return PackageSourceParser.parseGithubPackage(self) + } else { + return nil + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift b/CodeEditModules/Sources/CELSP/Registry/RegistryItemTemplateParser.swift similarity index 99% rename from CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift rename to CodeEditModules/Sources/CELSP/Registry/RegistryItemTemplateParser.swift index 16d171b62f..e2c0699b13 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift +++ b/CodeEditModules/Sources/CELSP/Registry/RegistryItemTemplateParser.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditCore /// This parser is used to parse expressions that may be included in a field of a registry item. /// diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift b/CodeEditModules/Sources/CELSP/Registry/RegistryManager+HandleRegistryFile.swift similarity index 96% rename from CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift rename to CodeEditModules/Sources/CELSP/Registry/RegistryManager+HandleRegistryFile.swift index 86ec7ec2fe..20498fd1a1 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift +++ b/CodeEditModules/Sources/CELSP/Registry/RegistryManager+HandleRegistryFile.swift @@ -6,12 +6,13 @@ // import Foundation +import CodeEditCore extension RegistryManager { /// Downloads the latest registry func downloadRegistryItems() async { - isDownloadingRegistry = true - defer { isDownloadingRegistry = false } + viewState.isDownloadingRegistry = true + defer { viewState.isDownloadingRegistry = false } let registryData, checksumData: Data do { @@ -46,7 +47,7 @@ extension RegistryManager { try FileManager.default.removeItem(at: tempZipURL) try checksumData.write(to: checksumDestination) - downloadError = nil + viewState.downloadError = nil } catch { handleUpdateError(RegistryManagerError.writeFailed(error: error)) return @@ -64,7 +65,7 @@ extension RegistryManager { } func handleUpdateError(_ error: Error) { - self.downloadError = error + self.viewState.downloadError = error if let regError = error as? RegistryManagerError { switch regError { case .installationRunning: diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEditModules/Sources/CELSP/Registry/RegistryManager.swift similarity index 60% rename from CodeEdit/Features/LSP/Registry/RegistryManager.swift rename to CodeEditModules/Sources/CELSP/Registry/RegistryManager.swift index c287f85519..8ab776d67d 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEditModules/Sources/CELSP/Registry/RegistryManager.swift @@ -1,21 +1,22 @@ // -// Registry.swift +// RegistryManager.swift // CodeEdit // // Created by Abe Malla on 1/29/25. // import OSLog +import CodeEditSettings import Foundation import ZIPFoundation import Combine +import CodeEditCore @MainActor -final class RegistryManager: ObservableObject { - static let shared = RegistryManager() +public final class RegistryManager: RegistryManaging { let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RegistryManager") - let installPath = Settings.shared.baseURL.appending(path: "Language Servers") + let installPath: URL /// The URL of where the registry.json file will be downloaded from let registryURL = URL( @@ -26,30 +27,65 @@ final class RegistryManager: ObservableObject { string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" )! - @Published var isDownloadingRegistry: Bool = false - /// Holds an errors found while downloading the registry file. Needs a UI to dismiss, is logged. - @Published var downloadError: Error? - /// Any currently running installation operation. - @Published var runningInstall: PackageManagerInstallOperation? - private var installTask: Task? + /// Observable presentation state for the Settings extension pages. + /// The manager owns and feeds it; views observe it instead of the manager. + public let viewState = RegistryViewState() - /// Indicates if the manager is currently installing a package. - var isInstalling: Bool { - installTask != nil - } + private var installTask: Task? /// Reference to cached registry data. Will be removed from memory after a certain amount of time. private var cachedRegistry: CachedRegistry? - /// Timer to clear expired cache - private var cleanupTimer: Timer? - /// Public access to registry items with cache management - @Published public private(set) var registryItems: [RegistryItem] = [] + /// Timer to clear expired cache. + /// nonisolated(unsafe): scheduled and invalidated on the main actor; also + /// invalidated from `deinit`, which cannot be actor-isolated. + nonisolated(unsafe) private var cleanupTimer: Timer? + + /// Every mutation persists through the settings seam. Note that the initializer's seeding + /// assignment happens inside `init` and therefore does *not* fire `didSet` — construction + /// deliberately writes nothing back. + public private(set) var installedLanguageServers: [String: LanguageServerSettings.Installed] { + didSet { + var settings = settingsAccessor.value(LanguageServerSettings.self) + settings.installedLanguageServers = installedLanguageServers + settingsAccessor.setValue(settings) + } + } - @AppSettings(\.languageServers.installedLanguageServers) - var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] + private let eventBus: EventBus + private let errorNotifier: ErrorNotifying + private let shellClient: ShellClientProtocol + private let settingsAccessor: SettingsAccessing + + public init( + eventBus: EventBus, + errorNotifier: ErrorNotifying, + shellClient: ShellClientProtocol, + settingsAccessor: SettingsAccessing, + installPath: URL + ) { + self.installPath = installPath + self.eventBus = eventBus + self.errorNotifier = errorNotifier + self.shellClient = shellClient + self.settingsAccessor = settingsAccessor + self.installedLanguageServers = settingsAccessor + .value(LanguageServerSettings.self) + .installedLanguageServers + } - init() { - // Load the registry items from disk again after cache expires + deinit { + cleanupTimer?.invalidate() + } + + private var didStartInitialLoad = false + + /// Loads the registry catalog on first demand (from disk, else network). + /// Idempotent — safe to call on every appearance of the Extensions page. + /// The cache-expiry timer set by `setRegistryItems` continues the refresh + /// cycle after the first load. + public func loadRegistryIfNeeded() { + guard !didStartInitialLoad else { return } + didStartInitialLoad = true if let items = loadItemsFromDisk() { setRegistryItems(items) } else { @@ -59,20 +95,16 @@ final class RegistryManager: ObservableObject { } } - deinit { - cleanupTimer?.invalidate() - } - // MARK: - Enable/Disable - func setPackageEnabled(packageName: String, enabled: Bool) { + public func setPackageEnabled(packageName: String, enabled: Bool) { installedLanguageServers[packageName]?.isEnabled = enabled } // MARK: - Uninstall @MainActor - func removeLanguageServer(packageName: String) async throws { + public func removeLanguageServer(packageName: String) async throws { let packageName = packageName.removingPercentEncoding ?? packageName let packageDirectory = installPath.appending(path: packageName) @@ -82,15 +114,9 @@ final class RegistryManager: ObservableObject { } // Add to activity viewer - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": packageName, - "action": "create", - "title": "Removing \(packageName)" - ] - ) + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: packageName, title: "Removing \(packageName)")) + )) do { try await Task.detached(priority: .userInitiated) { @@ -105,20 +131,20 @@ final class RegistryManager: ObservableObject { // MARK: - Install public func installOperation(package: RegistryItem) throws -> PackageManagerInstallOperation { - guard !isInstalling else { + guard !viewState.isInstalling else { throw RegistryManagerError.installationRunning } guard let method = package.installMethod, - let manager = method.packageManager(installPath: installPath) else { + let manager = method.packageManager(installPath: installPath, shellClient: shellClient) else { throw PackageManagerError.invalidConfiguration } let installSteps = try manager.install(method: method) - return PackageManagerInstallOperation(package: package, steps: installSteps) + return PackageManagerInstallOperation(package: package, steps: installSteps, shellClient: shellClient) } /// Starts the actual installation process for a package public func startInstallation(operation installOperation: PackageManagerInstallOperation) throws { - guard !isInstalling else { + guard !viewState.isInstalling else { throw RegistryManagerError.installationRunning } @@ -131,19 +157,20 @@ final class RegistryManager: ObservableObject { } private func installPackage(operation: PackageManagerInstallOperation, method: InstallationMethod) { + viewState.isInstalling = true installTask = Task { [weak self] in defer { self?.installTask = nil - self?.runningInstall = nil + self?.viewState.isInstalling = false + self?.viewState.runningInstall = nil } - self?.runningInstall = operation + self?.viewState.runningInstall = operation // Add to activity viewer let activityTitle = "\(operation.package.name)\("@" + (method.version ?? "latest"))" - TaskNotificationHandler.postTask( - action: .create, - model: TaskNotificationModel(id: operation.package.name, title: "Installing \(activityTitle)") - ) + self?.eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: operation.package.name, title: "Installing \(activityTitle)")) + )) guard !Task.isCancelled else { return } @@ -167,9 +194,11 @@ final class RegistryManager: ObservableObject { /// Cancel the currently running installation public func cancelInstallation() { - runningInstall?.cancel() + viewState.runningInstall?.cancel() installTask?.cancel() installTask = nil + viewState.isInstalling = false + viewState.runningInstall = nil } /// Updates the activity viewer with the status of the language server installation @@ -180,34 +209,23 @@ final class RegistryManager: ObservableObject { fail failed: Bool ) { if failed { - NotificationManager.shared.post( - iconSymbol: "xmark.circle", - iconColor: .clear, + errorNotifier.postError( title: "Could not install \(activityName)", - description: "There was a problem during installation.", - actionButtonTitle: "Done", - action: {}, + description: "There was a problem during installation." ) } else { - TaskNotificationHandler.postTask( - action: .update, - model: TaskNotificationModel(id: id, title: "Successfully installed \(activityName)", isLoading: false) - ) - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": id, - "action": "deleteWithDelay", - "delay": 5.0, - ] - ) + eventBus.publish(TaskNotificationEvent( + .update(id: id, title: "Successfully installed \(activityName)", isLoading: false) + )) + eventBus.publish(TaskNotificationEvent( + .deleteWithDelay(id: id, delay: 5.0) + )) } } // MARK: - Cache - func setRegistryItems(_ items: [RegistryItem]) { + public func setRegistryItems(_ items: [RegistryItem]) { cachedRegistry = CachedRegistry(items: items) // Set up timer to clear the cache after expiration @@ -223,7 +241,7 @@ final class RegistryManager: ObservableObject { } } - registryItems = items + viewState.registryItems = items } } diff --git a/CodeEditModules/Sources/CELSP/Registry/RegistryManaging.swift b/CodeEditModules/Sources/CELSP/Registry/RegistryManaging.swift new file mode 100644 index 0000000000..afb6f22ad5 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Registry/RegistryManaging.swift @@ -0,0 +1,28 @@ +// +// RegistryManaging.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 11/04/26. +// + +import Foundation +import CodeEditSettings +import CodeEditCore + +/// Protocol for managing the language server registry. +/// +/// A pure command service: observable presentation state lives on the +/// concrete ``RegistryViewState`` exposed via ``viewState`` (views observe +/// that; a view-state is concrete by nature). +@MainActor +public protocol RegistryManaging: AnyObject { + var viewState: RegistryViewState { get } + var installedLanguageServers: [String: LanguageServerSettings.Installed] { get } + + func loadRegistryIfNeeded() + func setPackageEnabled(packageName: String, enabled: Bool) + func removeLanguageServer(packageName: String) async throws + func installOperation(package: RegistryItem) throws -> PackageManagerInstallOperation + func startInstallation(operation: PackageManagerInstallOperation) throws + func cancelInstallation() +} diff --git a/CodeEditModules/Sources/CELSP/Registry/RegistryViewState.swift b/CodeEditModules/Sources/CELSP/Registry/RegistryViewState.swift new file mode 100644 index 0000000000..315d002431 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Registry/RegistryViewState.swift @@ -0,0 +1,24 @@ +// +// RegistryViewState.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +import Foundation +import CodeEditCore + +/// Observable presentation state for the language-server registry. +/// Owned and fed by ``RegistryManager``; observed by the Settings extension +/// pages. Split out so the manager itself is a pure command service. +@MainActor +public final class RegistryViewState: ObservableObject { + @Published public internal(set) var isDownloadingRegistry: Bool = false + /// Holds any error found while downloading the registry file. Needs a UI to dismiss, is logged. + @Published public internal(set) var downloadError: Error? + /// Any currently running installation operation. + @Published public internal(set) var runningInstall: PackageManagerInstallOperation? + /// Indicates if the manager is currently installing a package. + @Published public internal(set) var isInstalling: Bool = false + @Published public internal(set) var registryItems: [RegistryItem] = [] +} diff --git a/CodeEditModules/Sources/CELSP/Service/AppLanguageServicesProvider.swift b/CodeEditModules/Sources/CELSP/Service/AppLanguageServicesProvider.swift new file mode 100644 index 0000000000..9e92754b3a --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Service/AppLanguageServicesProvider.swift @@ -0,0 +1,25 @@ +// +// AppLanguageServicesProvider.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 2026/07/09. +// + +import CodeEditDocument + +@MainActor +public final class AppLanguageServicesProvider: LanguageServicesProvider { + private let lspService: LSPService + + public init(lspService: LSPService) { + self.lspService = lspService + } + + public func languageServices(for document: CodeFileDocument) -> LanguageServices { + let objects = lspService.languageServerObjects(for: document) + return LanguageServices( + textCoordinator: objects.textCoordinator, + highlightProvider: objects.highlightProvider + ) + } +} diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEditModules/Sources/CELSP/Service/LSPService+Events.swift similarity index 94% rename from CodeEdit/Features/LSP/Service/LSPService+Events.swift rename to CodeEditModules/Sources/CELSP/Service/LSPService+Events.swift index 41d61f72ea..95812036ef 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEditModules/Sources/CELSP/Service/LSPService+Events.swift @@ -6,7 +6,7 @@ // import Foundation -import LanguageClient +@preconcurrency import LanguageClient import LanguageServerProtocol extension LSPService { @@ -16,9 +16,10 @@ extension LSPService { return } - // Create a new Task to listen to the events + // Capture the connection on the main actor; the detached task only awaits its events. + let lspInstance = languageClient.lspInstance let task = Task.detached { [weak self] in - for await event in languageClient.lspInstance.eventSequence { + for await event in lspInstance.eventSequence { await self?.handleEvent(event, for: key) } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEditModules/Sources/CELSP/Service/LSPService.swift similarity index 73% rename from CodeEdit/Features/LSP/Service/LSPService.swift rename to CodeEditModules/Sources/CELSP/Service/LSPService.swift index 373b951660..dcafa67350 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEditModules/Sources/CELSP/Service/LSPService.swift @@ -5,9 +5,11 @@ // Created by Abe Malla on 2/7/24. // +import CodeEditCore import os.log +import CodeEditSettings +import CodeEditDocument import JSONRPC -import SwiftUI import Foundation import LanguageClient import LanguageServerProtocol @@ -99,36 +101,65 @@ import CodeEditLanguages /// } /// ``` @MainActor -final class LSPService: ObservableObject { - typealias LanguageServerType = LanguageServer +public final class LSPService: LSPServiceProtocol { + public typealias LanguageServerType = LanguageServer let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") - struct ClientKey: Hashable, Equatable { - let languageId: LanguageIdentifier - let workspacePath: String + /// Observable list of running servers for UI (the output-source picker). + /// The service owns and feeds it; views observe it instead of the service. + public let serverListState = LanguageServerListState() - init(_ languageId: LanguageIdentifier, _ workspacePath: String) { + public struct ClientKey: Hashable, Equatable, Sendable { + public let languageId: LanguageIdentifier + public let workspacePath: String + + public init(_ languageId: LanguageIdentifier, _ workspacePath: String) { self.languageId = languageId self.workspacePath = workspacePath } } /// Holds the active language clients - @Published var languageClients: [ClientKey: LanguageServerType] = [:] + public var languageClients: [ClientKey: LanguageServerType] = [:] /// Holds the language server configurations for all the installed language servers var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] /// Holds all the event listeners for each active language client var eventListeningTasks: [ClientKey: Task] = [:] - @AppSettings(\.developerSettings.lspBinaries) - var lspBinaries + /// Per-document language-server objects (content coordinator + highlight provider), keyed by + /// document URI. Owned here so `CodeFileDocument` need not depend on LSP types. Entries are + /// created on demand and removed when the document closes. + private var documentObjects: [String: LanguageServerDocumentObjects] = [:] + + /// Returns the language-server objects for a document, creating and storing them on first use. + /// A document without a URI (e.g. untitled) gets a fresh, unstored instance — it has no server. + func languageServerObjects(for document: CodeFileDocument) -> LanguageServerDocumentObjects { + guard let uri = document.languageServerURI else { + return LanguageServerDocumentObjects() + } + if let existing = documentObjects[uri] { + return existing + } + let created = LanguageServerDocumentObjects() + documentObjects[uri] = created + return created + } + + /// Drops the stored objects for a document URI. Called when a document closes. + func removeLanguageServerObjects(for uri: String) { + documentObjects[uri] = nil + } - @Environment(\.openWindow) - private var openWindow + /// Resolves the root URL of the workspace that owns a file URL. Property-injected (not + /// init-injected) by the composition root because the window manager's own construction + /// consumes `LSPService` — init injection in both directions would recurse. Assigned + /// before any document opens. + public var workspaceFinder: (URL) -> URL? = { _ in nil } - init() { + public init(settingsReader: SettingsReading) { // Load the LSP binaries from the developer menu + let lspBinaries = settingsReader.value(DeveloperSettings.self).lspBinaries for binary in lspBinaries { if let language = LanguageIdentifier(rawValue: binary.key) { self.languageConfigs[language] = LanguageServerBinary( @@ -138,28 +169,6 @@ final class LSPService: ObservableObject { ) } } - - NotificationCenter.default.addObserver( - forName: CodeFileDocument.didOpenNotification, - object: nil, - queue: .main - ) { notification in - MainActor.assumeIsolated { - guard let document = notification.object as? CodeFileDocument else { return } - self.openDocument(document) - } - } - - NotificationCenter.default.addObserver( - forName: CodeFileDocument.didCloseNotification, - object: nil, - queue: .main - ) { notification in - MainActor.assumeIsolated { - guard let url = notification.object as? URL else { return } - self.closeDocument(url) - } - } } /// Gets the language server for the specified language and workspace. @@ -196,9 +205,20 @@ final class LSPService: ObservableObject { let server = try await LanguageServerType.createServer( for: languageId, with: serverBinary, - workspacePath: workspacePath + workspacePath: workspacePath, + provideObjects: { [weak self] document in + self?.languageServerObjects(for: document) ?? LanguageServerDocumentObjects() + }, + clearObjects: { [weak self] uri in + self?.removeLanguageServerObjects(for: uri) + } ) languageClients[ClientKey(languageId, workspacePath)] = server + serverListState.add(RunningLanguageServer( + workspacePath: workspacePath, + languageId: languageId, + logContainer: server.logContainer + )) logger.info("Successfully started \(languageId.rawValue) language server") self.startListeningToEvents(for: ClientKey(languageId, workspacePath)) @@ -210,12 +230,12 @@ final class LSPService: ObservableObject { /// Notify all relevant language clients that a document was opened. /// - Note: Must be invoked after the contents of the file are available. /// - Parameter document: The code document that was opened. - func openDocument(_ document: CodeFileDocument) { - guard let workspace = document.findWorkspace(), - let workspacePath = workspace.fileURL?.absolutePath, + public func openDocument(_ document: CodeFileDocument) { + guard let workspaceURL = document.fileURL.flatMap({ workspaceFinder($0) }), let lspLanguage = document.getLanguage().lspLanguage else { return } + let workspacePath = workspaceURL.absolutePath Task { let languageServer: LanguageServerType do { @@ -225,7 +245,6 @@ final class LSPService: ObservableObject { languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) } } catch { - notifyToInstallLanguageServer(language: lspLanguage) // swiftlint:disable:next line_length self.logger.error("Failed to find/start server for language: \(lspLanguage.rawValue), workspace: \(workspacePath, privacy: .private)") return @@ -242,7 +261,8 @@ final class LSPService: ObservableObject { /// Notify all relevant language clients that a document was closed. /// - Parameter url: The url of the document that was closed - func closeDocument(_ url: URL) { + public func closeDocument(_ url: URL) { + removeLanguageServerObjects(for: url.lspURI) guard let languageClient = languageClient(forDocument: url) else { return } Task { do { @@ -258,12 +278,12 @@ final class LSPService: ObservableObject { /// Close all language clients for a workspace. /// - /// This is intentionally synchronous so we can exit from the workspace document's ``WorkspaceDocument/close()`` + /// This is intentionally synchronous so the app's workspace-close path can call it on the way out /// method ASAP. /// /// Errors thrown in this method are logged and otherwise not handled. /// - Parameter workspacePath: The path of the workspace. - func closeWorkspace(_ workspacePath: String) { + public func closeWorkspace(_ workspacePath: String) { Task { let clientKeys = self.languageClients.filter({ $0.key.workspacePath == workspacePath }) for (key, languageClient) in clientKeys { @@ -276,6 +296,7 @@ final class LSPService: ObservableObject { for (key, _) in clientKeys { self.languageClients.removeValue(forKey: key) } + self.serverListState.removeAll(workspacePath: workspacePath) } } @@ -298,25 +319,25 @@ final class LSPService: ObservableObject { throw error } languageClients.removeValue(forKey: ClientKey(languageId, workspacePath)) + serverListState.remove(workspacePath: workspacePath, languageId: languageId) logger.info("Server stopped for language \(languageId.rawValue)") stopListeningToEvents(for: ClientKey(languageId, workspacePath)) } /// Goes through all active language servers and attempts to shut them down. - func stopAllServers() async { - await withTaskGroup(of: Void.self) { group in - for (key, server) in languageClients { - group.addTask { - do { - try await server.shutdown() - } catch { - self.logger.warning("Shutting down \(key.languageId.rawValue): Error \(error)") - } - } + /// Sequential: `LanguageServer` is main-actor isolated, and the app's quit path + /// bounds this with a timeout + SIGKILL fallback. + public func stopAllServers() async { + for (key, server) in languageClients { + do { + try await server.shutdown() + } catch { + self.logger.warning("Shutting down \(key.languageId.rawValue): Error \(error)") } } languageClients.removeAll() + serverListState.removeAll() eventListeningTasks.forEach { (_, value) in value.cancel() } @@ -324,42 +345,13 @@ final class LSPService: ObservableObject { } /// Call this when a server is refusing to terminate itself. Sends the `SIGKILL` signal to all lsp processes. - func killAllServers() { + public func killAllServers() { for (_, server) in languageClients { kill(server.pid, SIGKILL) } } } -extension LSPService { - private func notifyToInstallLanguageServer(language lspLanguage: LanguageIdentifier) { - // TODO: Re-Enable when this is more fleshed out (don't send duplicate notifications in a session) - return - // FIXME: Unreachable code - remove or re-enable when ready - /* - let lspLanguageTitle = lspLanguage.rawValue.capitalized - let notificationTitle = "Install \(lspLanguageTitle) Language Server" - // Make sure the user doesn't have the same existing notification - guard !NotificationManager.shared.notifications.contains(where: { $0.title == notificationTitle }) else { - return - } - - NotificationManager.shared.post( - iconSymbol: "arrow.down.circle", - iconColor: .clear, - title: notificationTitle, - description: "Install the \(lspLanguageTitle) language server to enable code intelligence features.", - actionButtonTitle: "Install" - ) { [weak self] in - // TODO: Warning: - // Accessing Environment's value outside of being installed on a View. - // This will always read the default value and will not update - self?.openWindow(sceneID: .settings) - } - */ - } -} - // MARK: - Errors enum ServerManagerError: Error { diff --git a/CodeEdit/Features/LSP/Service/LSPServiceError.swift b/CodeEditModules/Sources/CELSP/Service/LSPServiceError.swift similarity index 100% rename from CodeEdit/Features/LSP/Service/LSPServiceError.swift rename to CodeEditModules/Sources/CELSP/Service/LSPServiceError.swift diff --git a/CodeEditModules/Sources/CELSP/Service/LSPServiceProtocol.swift b/CodeEditModules/Sources/CELSP/Service/LSPServiceProtocol.swift new file mode 100644 index 0000000000..5a8d8260c3 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Service/LSPServiceProtocol.swift @@ -0,0 +1,23 @@ +// +// LSPServiceProtocol.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 11/04/26. +// + +import Foundation +import CodeEditDocument + +/// Protocol for managing Language Server Protocol services. +/// +/// Note: `languageClients` is not included here because consumers that need +/// reactive observation of `@Published` properties require the concrete type. +/// Use `LSPService` directly in those cases. +@MainActor +public protocol LSPServiceProtocol: AnyObject { + func openDocument(_ document: CodeFileDocument) + func closeDocument(_ url: URL) + func closeWorkspace(_ workspacePath: String) + func stopAllServers() async + func killAllServers() +} diff --git a/CodeEditModules/Sources/CELSP/Service/LanguageServerListState.swift b/CodeEditModules/Sources/CELSP/Service/LanguageServerListState.swift new file mode 100644 index 0000000000..4bb019eb8d --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Service/LanguageServerListState.swift @@ -0,0 +1,41 @@ +// +// LanguageServerListState.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +import Foundation +import LanguageServerProtocol + +/// A running language server, as presented to UI (the output-source picker). +public struct RunningLanguageServer: Identifiable { + public let workspacePath: String + public let languageId: LanguageIdentifier + public let logContainer: LanguageServerLogContainer + public var id: String { workspacePath + languageId.rawValue } +} + +/// Observable list of running language servers. Owned and fed by +/// ``LSPService``; observed by the utility-area output-source picker. +@MainActor +public final class LanguageServerListState: ObservableObject { + @Published public private(set) var runningServers: [RunningLanguageServer] = [] + + func add(_ server: RunningLanguageServer) { + runningServers.removeAll { $0.id == server.id } + runningServers.append(server) + } + + func remove(workspacePath: String, languageId: LanguageIdentifier) { + runningServers.removeAll { $0.workspacePath == workspacePath && $0.languageId == languageId } + } + + func removeAll(workspacePath: String) { + runningServers.removeAll { $0.workspacePath == workspacePath } + } + + func removeAll() { + runningServers.removeAll() + } +} diff --git a/CodeEditModules/Sources/CELSP/Service/LanguageServerLogContainer.swift b/CodeEditModules/Sources/CELSP/Service/LanguageServerLogContainer.swift new file mode 100644 index 0000000000..34097b1113 --- /dev/null +++ b/CodeEditModules/Sources/CELSP/Service/LanguageServerLogContainer.swift @@ -0,0 +1,60 @@ +// +// LanguageServerLogContainer.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import Foundation +import LanguageServerProtocol + +/// Collects a language server's log messages. UtilityArea adapts this to its output +/// protocols app-side (`LanguageServerLogContainer+UtilityArea.swift`). +/// +/// `@unchecked Sendable`: logs arrive from server event streams and process +/// termination handlers on arbitrary threads; all access to `logs` is guarded +/// by `logLock`. +public final class LanguageServerLogContainer: @unchecked Sendable { + public struct LanguageServerMessage: Identifiable { + public let log: LogMessageParams + public var id: UUID = UUID() + + public var message: String { + log.message + } + + public var date: Date = Date() + public var subsystem: String? + public var category: String? + } + + public let id: String + + private let streamContinuation: AsyncStream.Continuation + private let stream: AsyncStream + private let logLock = NSLock() + private var logs: [LanguageServerMessage] = [] + + public init(language: LanguageIdentifier) { + id = language.rawValue + (stream, streamContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(0) + ) + } + + public func appendLog(_ log: LogMessageParams) { + let message = LanguageServerMessage(log: log) + logLock.withLock { + logs.append(message) + } + streamContinuation.yield(message) + } + + public func cachedMessages() -> [LanguageServerMessage] { + logLock.withLock { logs } + } + + public func streamMessages() -> AsyncStream { + stream + } +} diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEditModules/Sources/CENotifications/CENotification.swift similarity index 92% rename from CodeEdit/Features/Notifications/Models/CENotification.swift rename to CodeEditModules/Sources/CENotifications/CENotification.swift index f71d39a49e..c045d1f9b1 100644 --- a/CodeEdit/Features/Notifications/Models/CENotification.swift +++ b/CodeEditModules/Sources/CENotifications/CENotification.swift @@ -8,8 +8,8 @@ import Foundation import SwiftUI -struct CENotification: Identifiable, Equatable { - let id: UUID +public struct CENotification: Identifiable, Equatable { + public let id: UUID let icon: IconType let title: String let description: String @@ -20,13 +20,13 @@ struct CENotification: Identifiable, Equatable { let timestamp: Date var isBeingDismissed: Bool = false - enum IconType { + public enum IconType { case symbol(name: String, color: Color?) case image(Image) case text(String, backgroundColor: Color?, textColor: Color?) } - init( + public init( id: UUID = UUID(), iconSymbol: String, iconColor: Color? = nil, @@ -49,7 +49,7 @@ struct CENotification: Identifiable, Equatable { ) } - init( + public init( id: UUID = UUID(), iconText: String, iconTextColor: Color? = nil, @@ -73,7 +73,7 @@ struct CENotification: Identifiable, Equatable { ) } - init( + public init( id: UUID = UUID(), iconImage: Image, title: String, @@ -116,7 +116,7 @@ struct CENotification: Identifiable, Equatable { self.timestamp = Date() } - static func == (lhs: CENotification, rhs: CENotification) -> Bool { + public static func == (lhs: CENotification, rhs: CENotification) -> Bool { lhs.id == rhs.id } } diff --git a/CodeEditModules/Sources/CENotifications/Environment+NotificationManager.swift b/CodeEditModules/Sources/CENotifications/Environment+NotificationManager.swift new file mode 100644 index 0000000000..566eec1933 --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/Environment+NotificationManager.swift @@ -0,0 +1,22 @@ +// +// Environment+NotificationManager.swift +// Notifications +// +// Created by Matthijs Eikelenboom on 10/07/2026. +// + +import SwiftUI + +private struct NotificationManagerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: NotificationManaging? = nil +} + +extension EnvironmentValues { + /// The app-wide notification manager. Optional because notification UI rendered + /// outside a configured app context (previews, tests) has no manager; consumers + /// no-op via optional chaining. Injected by the app shell. + public var notificationManager: NotificationManaging? { + get { self[NotificationManagerKey.self] } + set { self[NotificationManagerKey.self] = newValue } + } +} diff --git a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift b/CodeEditModules/Sources/CENotifications/NotificationManager+Delegate.swift similarity index 59% rename from CodeEdit/Features/Notifications/NotificationManager+Delegate.swift rename to CodeEditModules/Sources/CENotifications/NotificationManager+Delegate.swift index 967023db1f..087da22489 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift +++ b/CodeEditModules/Sources/CENotifications/NotificationManager+Delegate.swift @@ -9,33 +9,40 @@ import AppKit import UserNotifications extension NotificationManager: UNUserNotificationCenterDelegate { - func userNotificationCenter( + // System-invoked (not guaranteed main); `nonisolated` + hop to the main actor for state. + nonisolated public func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - if let notification = notifications.first(where: { - $0.id.uuidString == response.notification.request.identifier - }) { + // Extract Sendable values before crossing to the main actor (UNNotificationResponse isn't Sendable). + // Extract Sendable values; the completion handler isn't Sendable so call it here + // (it only signals the delegate finished), and run the main-actor action work async. + let identifier = response.notification.request.identifier + let actionIdentifier = response.actionIdentifier + Task { @MainActor in + guard let notification = self.notifications.first(where: { $0.id.uuidString == identifier }) else { + return + } // Focus CodeEdit and run action if action button was clicked - if response.actionIdentifier == "ACTION_BUTTON" || - response.actionIdentifier == UNNotificationDefaultActionIdentifier { + if actionIdentifier == "ACTION_BUTTON" || + actionIdentifier == UNNotificationDefaultActionIdentifier { NSApp.activate(ignoringOtherApps: true) notification.action() } // Remove the notification for both action and dismiss - if response.actionIdentifier == "ACTION_BUTTON" || - response.actionIdentifier == UNNotificationDefaultActionIdentifier || - response.actionIdentifier == UNNotificationDismissActionIdentifier { - dismissNotification(notification) + if actionIdentifier == "ACTION_BUTTON" || + actionIdentifier == UNNotificationDefaultActionIdentifier || + actionIdentifier == UNNotificationDismissActionIdentifier { + self.dismissNotification(notification) } } completionHandler() } - func userNotificationCenter( + nonisolated public func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void diff --git a/CodeEdit/Features/Notifications/NotificationManager+System.swift b/CodeEditModules/Sources/CENotifications/NotificationManager+System.swift similarity index 100% rename from CodeEdit/Features/Notifications/NotificationManager+System.swift rename to CodeEditModules/Sources/CENotifications/NotificationManager+System.swift diff --git a/CodeEditModules/Sources/CENotifications/NotificationManager.swift b/CodeEditModules/Sources/CENotifications/NotificationManager.swift new file mode 100644 index 0000000000..5e0f964d2e --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/NotificationManager.swift @@ -0,0 +1,101 @@ +// +// NotificationManager.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import SwiftUI +import Combine +import UserNotifications +import CodeEditCore + +/// Manages the application's notification system, handling both in-app notifications and system notifications. +/// This class is responsible for: +/// - Managing notification persistence +/// - Tracking notification read status +/// - Broadcasting notifications to workspaces +@MainActor +public final class NotificationManager: NSObject, NotificationManaging { + + /// Collection of all notifications, both read and unread + @Published public private(set) var notifications: [CENotification] = [] + + /// Fires on any change to ``notifications``, including `isRead` mutations. + public var notificationsPublisher: AnyPublisher<[CENotification], Never> { + $notifications.eraseToAnyPublisher() + } + + private let eventBus: EventBus + + private var isAppActive: Bool = true + + /// Dismisses a specific notification + public func dismissNotification(_ notification: CENotification) { + notifications.removeAll(where: { $0.id == notification.id }) + markAsRead(notification) + + // Remove system notification if it exists + removeSystemNotification(notification) + + eventBus.publish(CENotificationEvent(.dismissed(id: notification.id))) + } + + /// Marks a notification as read + /// - Parameter notification: The notification to mark as read + public func markAsRead(_ notification: CENotification) { + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index].isRead = true + } + } + + public init(eventBus: EventBus) { + self.eventBus = eventBus + super.init() + setupNotificationDelegate() + + // Observe app active state + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidResignActive), + name: NSApplication.didResignActiveNotification, + object: nil + ) + } + + @objc + private func handleAppDidBecomeActive() { + isAppActive = true + // Remove any system notifications when app becomes active + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + + @objc + private func handleAppDidResignActive() { + isAppActive = false + } + + /// Posts a notification to workspaces and system. + /// + /// Runs synchronously on the main actor (the class is `@MainActor` and all callers are too); + /// the previous `DispatchQueue.main.async` hop only ensured main-thread execution, which the + /// actor now guarantees — call order (and thus notification order) is preserved. + public func post(_ notification: CENotification) { + notifications.append(notification) + + // Always notify workspaces of new notification + eventBus.publish(CENotificationEvent(.added(id: notification.id))) + + // Additionally show system notification when app is in background + if !isAppActive { + showSystemNotification(notification) + } + } +} diff --git a/CodeEditModules/Sources/CENotifications/NotificationManaging.swift b/CodeEditModules/Sources/CENotifications/NotificationManaging.swift new file mode 100644 index 0000000000..83888cb63e --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/NotificationManaging.swift @@ -0,0 +1,124 @@ +// +// NotificationManaging.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 11/04/26. +// + +import SwiftUI +import Combine + +/// Protocol for managing application notifications. +/// +/// `@MainActor`: notifications drive UI (the panel, banner, and toolbar badge), so the +/// whole subsystem is main-actor-isolated. All consumers (LSPService, RegistryManager, +/// the panel view-model, views) are already `@MainActor`. +@MainActor +public protocol NotificationManaging: AnyObject { + /// Collection of all notifications, both read and unread. + var notifications: [CENotification] { get } + + /// Fires on any change to ``notifications``, including `isRead` mutations. Replays the current value. + var notificationsPublisher: AnyPublisher<[CENotification], Never> { get } + + /// Posts a new notification. + func post(_ notification: CENotification) + + func dismissNotification(_ notification: CENotification) + func markAsRead(_ notification: CENotification) +} + +public extension NotificationManaging { + /// Number of unread notifications. + var unreadCount: Int { + notifications.filter { !$0.isRead }.count + } + + /// Posts a new notification + /// - Parameters: + /// - iconSymbol: SF Symbol or CodeEditSymbol name for the notification icon + /// - iconColor: Color for the icon + /// - title: Main notification title + /// - description: Detailed notification message + /// - actionButtonTitle: Title for the action button + /// - action: Closure to execute when action button is clicked + /// - isSticky: Whether the notification should persist until manually dismissed + func post( + iconSymbol: String, + iconColor: Color? = Color(.systemBlue), + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + post(CENotification( + iconSymbol: iconSymbol, + iconColor: iconColor, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: false + )) + } + + /// Posts a new notification + /// - Parameters: + /// - iconImage: Image for the notification icon + /// - title: Main notification title + /// - description: Detailed notification message + /// - actionButtonTitle: Title for the action button + /// - action: Closure to execute when action button is clicked + /// - isSticky: Whether the notification should persist until manually dismissed + func post( + iconImage: Image, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + post(CENotification( + iconImage: iconImage, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky + )) + } + + /// Posts a new notification + /// - Parameters: + /// - iconText: Text or emoji for the notification icon + /// - iconTextColor: Color of the text/emoji (defaults to primary label color) + /// - iconColor: Background color for the icon + /// - title: Main notification title + /// - description: Detailed notification message + /// - actionButtonTitle: Title for the action button + /// - action: Closure to execute when action button is clicked + /// - isSticky: Whether the notification should persist until manually dismissed + func post( + iconText: String, + iconTextColor: Color? = nil, + iconColor: Color? = Color(.systemBlue), + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + post(CENotification( + iconText: iconText, + iconTextColor: iconTextColor, + iconColor: iconColor, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky + )) + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationBannerView.swift similarity index 95% rename from CodeEdit/Features/Notifications/Views/NotificationBannerView.swift rename to CodeEditModules/Sources/CENotifications/Panel/NotificationBannerView.swift index 11a90696ac..1f1343fb0d 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationBannerView.swift @@ -6,13 +6,13 @@ // import SwiftUI +import CodeEditUI struct NotificationBannerView: View { @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject private var workspace: WorkspaceDocument - @ObservedObject private var notificationManager = NotificationManager.shared + @EnvironmentObject private var notificationPanel: NotificationPanelViewModel let notification: CENotification let onDismiss: () -> Void @@ -150,9 +150,9 @@ struct NotificationBannerView: View { } if hovering { - workspace.notificationPanel.pauseTimer() + notificationPanel.pauseTimer() } else { - workspace.notificationPanel.resumeTimer() + notificationPanel.resumeTimer() } } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelView.swift similarity index 70% rename from CodeEdit/Features/Notifications/Views/NotificationPanelView.swift rename to CodeEditModules/Sources/CENotifications/Panel/NotificationPanelView.swift index 0474639a5f..b3b58733fb 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelView.swift @@ -6,13 +6,13 @@ // import SwiftUI +import CodeEditUI -struct NotificationPanelView: View { - @EnvironmentObject private var workspace: WorkspaceDocument +public struct NotificationPanelView: View { + @EnvironmentObject private var notificationPanel: NotificationPanelViewModel @Environment(\.controlActiveState) private var controlActiveState - @ObservedObject private var notificationManager = NotificationManager.shared @FocusState private var isFocused: Bool // ID for the top anchor @@ -33,8 +33,8 @@ struct NotificationPanelView: View { } @ViewBuilder var notifications: some View { - let visibleNotifications = workspace.notificationPanel.activeNotifications.filter { - workspace.notificationPanel.isNotificationVisible($0) + let visibleNotifications = notificationPanel.activeNotifications.filter { + notificationPanel.isNotificationVisible($0) } VStack(spacing: 8) { @@ -42,15 +42,15 @@ struct NotificationPanelView: View { NotificationBannerView( notification: notification, onDismiss: { - workspace.notificationPanel.dismissNotification(notification) + notificationPanel.dismissNotification(notification) }, onAction: { notification.action() - if workspace.notificationPanel.isPresented { - workspace.notificationPanel.toggleNotificationsVisibility() - workspace.notificationPanel.dismissNotification(notification, disableAnimation: true) + if notificationPanel.isPresented { + notificationPanel.toggleNotificationsVisibility() + notificationPanel.dismissNotification(notification, disableAnimation: true) } else { - workspace.notificationPanel.dismissNotification(notification) + notificationPanel.dismissNotification(notification) } } ) @@ -79,10 +79,10 @@ struct NotificationPanelView: View { } ) .onPreferenceChange(ViewOffsetKey.self) { - if $0 <= 0.0 && !workspace.notificationPanel.scrolledToTop { - workspace.notificationPanel.scrolledToTop = true - } else if $0 > 0.0 && workspace.notificationPanel.scrolledToTop { - workspace.notificationPanel.scrolledToTop = false + if $0 <= 0.0 && !notificationPanel.scrolledToTop { + notificationPanel.scrolledToTop = true + } else if $0 > 0.0 && notificationPanel.scrolledToTop { + notificationPanel.scrolledToTop = false } } notifications @@ -101,13 +101,13 @@ struct NotificationPanelView: View { .scrollDisabled(!hasOverflow) .coordinateSpace(name: "scroll") .onChange(of: isFocused) { _, newValue in - workspace.notificationPanel.handleFocusChange(isFocused: newValue) + notificationPanel.handleFocusChange(isFocused: newValue) } .onChange(of: geometry.size.height) { _, newValue in updateOverflow(contentHeight: contentHeight, containerHeight: newValue) } - .onChange(of: workspace.notificationPanel.isPresented) { _, isPresented in - if !isPresented && !workspace.notificationPanel.scrolledToTop { + .onChange(of: notificationPanel.isPresented) { _, isPresented in + if !isPresented && !notificationPanel.scrolledToTop { // If scrolled, delay scroll animation until after notifications are hidden DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { withAnimation(.easeOut(duration: 0.3)) { @@ -117,15 +117,17 @@ struct NotificationPanelView: View { } } .allowsHitTesting( - workspace.notificationPanel.activeNotifications - .contains { workspace.notificationPanel.isNotificationVisible($0) } + notificationPanel.activeNotifications + .contains { notificationPanel.isNotificationVisible($0) } ) } } } } - var body: some View { + public init() {} + + public var body: some View { Group { if #available(macOS 14.0, *) { notificationsWithScrollView @@ -133,16 +135,16 @@ struct NotificationPanelView: View { .focusable() .focusEffectDisabled() .focused($isFocused) - .onChange(of: workspace.notificationPanel.isPresented) { _, isPresented in + .onChange(of: notificationPanel.isPresented) { _, isPresented in if isPresented { isFocused = true } } .onChange(of: controlActiveState) { _, newState in - if newState != .active && newState != .key && workspace.notificationPanel.isPresented { + if newState != .active && newState != .key && notificationPanel.isPresented { // Delay hiding notifications to match animation timing DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - workspace.notificationPanel.toggleNotificationsVisibility() + notificationPanel.toggleNotificationsVisibility() } } } @@ -153,12 +155,12 @@ struct NotificationPanelView: View { .opacity(controlActiveState == .active || controlActiveState == .key ? 1 : 0) .offset( x: (controlActiveState == .active || controlActiveState == .key) && - (workspace.notificationPanel.isPresented || workspace.notificationPanel.scrolledToTop) + (notificationPanel.isPresented || notificationPanel.scrolledToTop) ? 0 : 350 ) - .animation(.easeInOut(duration: 0.3), value: workspace.notificationPanel.isPresented) - .animation(.easeInOut(duration: 0.3), value: workspace.notificationPanel.scrolledToTop) + .animation(.easeInOut(duration: 0.3), value: notificationPanel.isPresented) + .animation(.easeInOut(duration: 0.3), value: notificationPanel.scrolledToTop) .animation(.easeInOut(duration: 0.2), value: controlActiveState) } } diff --git a/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+NotificationHandling.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+NotificationHandling.swift new file mode 100644 index 0000000000..b34a9739a0 --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+NotificationHandling.swift @@ -0,0 +1,122 @@ +// +// NotificationPanelViewModel+NotificationHandling.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import SwiftUI +import CodeEditCore + +/// Notification insertion, dismissal, and event handling. +extension NotificationPanelViewModel { + /// Inserts a notification in the correct position (sticky notifications on top) + func insertNotification(_ notification: CENotification) { + if notification.isSticky { + // Find the first sticky notification (to insert before it) + if let firstStickyIndex = activeNotifications.firstIndex(where: { $0.isSticky }) { + // Insert at the very start of sticky group + activeNotifications.insert(notification, at: firstStickyIndex) + } else { + // No sticky notifications yet, insert at the start + activeNotifications.insert(notification, at: 0) + } + } else { + // Find the first non-sticky notification + if let firstNonStickyIndex = activeNotifications.firstIndex(where: { !$0.isSticky }) { + // Insert at the start of non-sticky group + activeNotifications.insert(notification, at: firstNonStickyIndex) + } else { + // No non-sticky notifications yet, append at the end + activeNotifications.append(notification) + } + } + } + + /// Handles a new notification being added + func handleNewNotification(_ notification: CENotification) { + let operation = { + self.insertNotification(notification) + self.hiddenNotificationIds.remove(notification.id) + if !self.isPresented && !notification.isSticky { + self.startHideTimer(for: notification) + } + } + + if #available(macOS 26, *) { + withAnimation(.easeInOut(duration: 0.3), operation) { + self.onToolbarUpdateRequested?() + } + } else { + withAnimation(.easeInOut(duration: 0.3), operation) + } + } + + /// Dismisses a specific notification + func dismissNotification(_ notification: CENotification, disableAnimation: Bool = false) { + // Clean up timers + timers[notification.id]?.invalidate() + timers[notification.id] = nil + hiddenNotificationIds.remove(notification.id) + + // Mark as being dismissed for animation + if let index = activeNotifications.firstIndex(where: { $0.id == notification.id }) { + if disableAnimation { + self.activeNotifications.removeAll(where: { $0.id == notification.id }) + notificationManager.markAsRead(notification) + notificationManager.dismissNotification(notification) + return + } + + var dismissingNotification = activeNotifications[index] + dismissingNotification.isBeingDismissed = true + activeNotifications[index] = dismissingNotification + + // Wait for fade animation before removing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + withAnimation(.easeOut(duration: 0.2)) { + self.activeNotifications.removeAll(where: { $0.id == notification.id }) + if self.activeNotifications.isEmpty && self.isPresented { + self.isPresented = false + } + } + + self.notificationManager.markAsRead(notification) + self.notificationManager.dismissNotification(notification) + } + } + } + + /// Routes notification list mutations published by `NotificationManager` on the EventBus. + func handle(_ event: CENotificationEvent) { + switch event.action { + case .added(let id): + guard let notification = notificationManager.notifications.first(where: { $0.id == id }) else { + return + } + handleNewNotification(notification) + case .dismissed(let id): + handleNotificationRemoved(id: id) + } + } + + private func handleNotificationRemoved(id: UUID) { + let operation: () -> Void = { + self.activeNotifications.removeAll(where: { $0.id == id }) + + // If this was the last notification and they were manually shown, hide the panel + if self.activeNotifications.isEmpty && self.isPresented { + self.isPresented = false + } + } + + // Just remove from active notifications without triggering global state changes + if #available(macOS 26, *) { + withAnimation(.easeOut(duration: 0.2), operation) { + self.onToolbarUpdateRequested?() + } + } else { + withAnimation(.easeOut(duration: 0.2), operation) + } + } +} diff --git a/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+TimerManagement.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+TimerManagement.swift new file mode 100644 index 0000000000..70abadd535 --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+TimerManagement.swift @@ -0,0 +1,60 @@ +// +// NotificationPanelViewModel+TimerManagement.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import SwiftUI + +/// Auto-hide timer scheduling, pausing, and resuming. +extension NotificationPanelViewModel { + /// Starts the timer to automatically hide a notification + func startHideTimer(for notification: CENotification) { + guard !notification.isSticky && !isPresented else { return } + + timers[notification.id]?.invalidate() + timers[notification.id] = nil + + guard !isPaused else { return } + + let notificationId = notification.id + timers[notificationId] = Timer.scheduledTimer( + withTimeInterval: displayDuration, + repeats: false + ) { [weak self] _ in + // The timer is scheduled from the main actor, so it fires on the main run loop. + // Capture only the Sendable `id` (CENotification isn't Sendable — it carries a closure). + MainActor.assumeIsolated { + guard let self else { return } + self.timers[notificationId] = nil + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.allowsImplicitAnimation = true + + withAnimation(.easeInOut(duration: 0.3)) { + var newHiddenIds = self.hiddenNotificationIds + newHiddenIds.insert(notificationId) + self.hiddenNotificationIds = newHiddenIds + } + } + } + } + } + + /// Pauses all auto-hide timers + func pauseTimer() { + isPaused = true + timers.values.forEach { $0.invalidate() } + } + + /// Resumes all auto-hide timers + func resumeTimer() { + isPaused = false + // Only restart timers for notifications that are currently visible + activeNotifications + .filter { !$0.isSticky && isNotificationVisible($0) } + .forEach { startHideTimer(for: $0) } + } +} diff --git a/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+Visibility.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+Visibility.swift new file mode 100644 index 0000000000..ca382fdeeb --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel+Visibility.swift @@ -0,0 +1,85 @@ +// +// NotificationPanelViewModel+Visibility.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import SwiftUI + +/// Panel visibility control, focus handling, and show/hide transitions. +extension NotificationPanelViewModel { + /// Whether a notification should be visible in the panel + func isNotificationVisible(_ notification: CENotification) -> Bool { + if notification.isBeingDismissed { + return true // Always show notifications being dismissed + } + if notification.isSticky { + return true // Always show sticky notifications + } + if isPresented { + return true // Show all notifications when manually shown + } + return !hiddenNotificationIds.contains(notification.id) + } + + /// Handles focus changes for the notification panel + func handleFocusChange(isFocused: Bool) { + if !isFocused { + // Only hide if manually shown and focus is completely lost + if isPresented { + toggleNotificationsVisibility() + } + } + } + + /// Toggles visibility of notifications in the panel + func toggleNotificationsVisibility() { + if isPresented { + if !scrolledToTop { + // Just set isPresented to false to trigger the offset animation + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } + + // After the slide-out animation, hide notifications + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + // Hide non-sticky notifications + self.activeNotifications + .filter { !$0.isSticky } + .forEach { self.hiddenNotificationIds.insert($0.id) } + self.objectWillChange.send() + + // After notifications are hidden, reset scroll position + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + MainActor.assumeIsolated { + self?.scrolledToTop = true + } + } + } + } + } else { + // At top, just hide normally + hideNotifications() + } + } else { + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = true + hiddenNotificationIds.removeAll() + objectWillChange.send() + } + } + } + + func hideNotifications() { + withAnimation(.easeInOut(duration: 0.3)) { + self.isPresented = false + self.activeNotifications + .filter { !$0.isSticky } + .forEach { self.hiddenNotificationIds.insert($0.id) } + self.objectWillChange.send() + } + } +} diff --git a/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel.swift new file mode 100644 index 0000000000..a115522081 --- /dev/null +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationPanelViewModel.swift @@ -0,0 +1,84 @@ +// +// NotificationPanelViewModel.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import SwiftUI +import Combine +import CodeEditCore + +/// Coordinates notification display, auto-hide timers, panel visibility, and toolbar integration. +/// +/// Methods are organized across focused extensions: +/// - `+TimerManagement`: auto-hide scheduling, pause/resume +/// - `+Visibility`: panel show/hide, focus handling +/// - `+NotificationHandling`: insertion, dismissal, event handling +/// - `+Toolbar`: dynamic toolbar item management +@MainActor +public final class NotificationPanelViewModel: ObservableObject { + /// Currently displayed notifications in the panel + @Published var activeNotifications: [CENotification] = [] + + /// Whether notifications panel was manually shown via toolbar + @Published var isPresented: Bool = false + + /// Set of hidden notification IDs + @Published var hiddenNotificationIds: Set = [] + + @Published var scrolledToTop: Bool = true + + /// Number of unread notifications, republished from the notification manager for view observation. + @Published private(set) var unreadCount: Int = 0 + + /// Timers for notifications + var timers: [UUID: Timer] = [:] + + /// Display duration for notifications + let displayDuration: TimeInterval = 5.0 + + /// Whether notifications are paused + var isPaused: Bool = false + + /// Non-private so the app shell's toolbar extension can read `unreadCount` through it. + public let notificationManager: NotificationManaging + + let eventBus: EventBus + + private var cancellables = Set() + + /// A filtered list of active notifications. + public var visibleNotifications: [CENotification] { + activeNotifications.filter { !hiddenNotificationIds.contains($0.id) } + } + + public weak var windowController: NSWindowController? + + /// Hook set by the app shell to refresh the window toolbar's notification item when + /// notification state changes. Toolbar mutation is app-shell responsibility (it uses + /// app-defined `NSToolbarItem.Identifier`s), so the package only signals; the app acts. + public var onToolbarUpdateRequested: (() -> Void)? + + public init(notificationManager: NotificationManaging, eventBus: EventBus) { + self.notificationManager = notificationManager + self.eventBus = eventBus + + // Observe notification additions and dismissals + eventBus.subscribe(CENotificationEvent.self) + .receive(on: RunLoop.main) + .sink { [weak self] event in self?.handle(event) } + .store(in: &cancellables) + + // Republish the unread count for views (the manager is behind a protocol and not observable). + notificationManager.notificationsPublisher + .map { notifications in notifications.filter { !$0.isRead }.count } + .receive(on: RunLoop.main) + .assign(to: &$unreadCount) + + // Load initial notifications from NotificationManager + notificationManager.notifications.forEach { notification in + handleNewNotification(notification) + } + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEditModules/Sources/CENotifications/Panel/NotificationToolbarItem.swift similarity index 53% rename from CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift rename to CodeEditModules/Sources/CENotifications/Panel/NotificationToolbarItem.swift index ecf8ea94a5..f2110fd7db 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift +++ b/CodeEditModules/Sources/CENotifications/Panel/NotificationToolbarItem.swift @@ -7,24 +7,25 @@ import SwiftUI -struct NotificationToolbarItem: View { - @EnvironmentObject private var workspace: WorkspaceDocument - @ObservedObject private var notificationManager = NotificationManager.shared +public struct NotificationToolbarItem: View { + @EnvironmentObject private var notificationPanel: NotificationPanelViewModel @Environment(\.controlActiveState) private var controlActiveState - var body: some View { - let visibleNotifications = workspace.notificationPanel.visibleNotifications + public init() {} - if notificationManager.unreadCount > 0 || !visibleNotifications.isEmpty { + public var body: some View { + let visibleNotifications = notificationPanel.visibleNotifications + + if notificationPanel.unreadCount > 0 || !visibleNotifications.isEmpty { Button { - workspace.notificationPanel.toggleNotificationsVisibility() + notificationPanel.toggleNotificationsVisibility() } label: { HStack(spacing: 4) { Image(systemName: "bell.badge.fill") .symbolRenderingMode(.palette) .foregroundStyle(controlActiveState == .inactive ? .secondary : Color.accentColor, .primary) - Text("\(notificationManager.unreadCount)") + Text("\(notificationPanel.unreadCount)") .monospacedDigit() } } diff --git a/CodeEditModules/Sources/CESearch/Environment+WorkspaceFileOpener.swift b/CodeEditModules/Sources/CESearch/Environment+WorkspaceFileOpener.swift new file mode 100644 index 0000000000..21ca987696 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/Environment+WorkspaceFileOpener.swift @@ -0,0 +1,22 @@ +// +// Environment+WorkspaceFileOpener.swift +// Search +// +// Created by Matthijs Eikelenboom on 10/07/2026. +// + +import SwiftUI +import CodeEditCore + +private struct WorkspaceFileOpenerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: WorkspaceFileOpener = NoOpWorkspaceFileOpener() +} + +extension EnvironmentValues { + /// The command used to open a file in the owning workspace. + /// No-op by default (previews, tests); injected by the app shell. + public var workspaceFileOpener: WorkspaceFileOpener { + get { self[WorkspaceFileOpenerKey.self] } + set { self[WorkspaceFileOpenerKey.self] = newValue } + } +} diff --git a/CodeEdit/Utils/Extensions/Array/Array+Index.swift b/CodeEditModules/Sources/CESearch/Extensions/Array+Index.swift similarity index 52% rename from CodeEdit/Utils/Extensions/Array/Array+Index.swift rename to CodeEditModules/Sources/CESearch/Extensions/Array+Index.swift index 6cafca5ef2..705a32217c 100644 --- a/CodeEdit/Utils/Extensions/Array/Array+Index.swift +++ b/CodeEditModules/Sources/CESearch/Extensions/Array+Index.swift @@ -5,11 +5,13 @@ // Created by Abe Malla on 7/24/25. // -extension Array { +public extension Array { + /// The second element of the array, or `nil` if the array has fewer than two elements. var second: Element? { self.count > 1 ? self[1] : nil } + /// The third element of the array, or `nil` if the array has fewer than three elements. var third: Element? { self.count > 2 ? self[2] : nil } diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindModePicker.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindModePicker.swift similarity index 94% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindModePicker.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindModePicker.swift index 308a15af5e..625bdca797 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindModePicker.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindModePicker.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import CodeEditCore struct FindModePicker: View { var modes: [SearchModeModel] @@ -21,8 +22,6 @@ struct FindModePicker: View { @Environment(\.controlActiveState) private var activeState - @EnvironmentObject var workspace: WorkspaceDocument - @State var position: NSPoint? @State var isHovering: Bool = false @State private var button: NSPopUpButton? @@ -134,6 +133,7 @@ struct FindModePicker: View { return Coordinator(self) } + @MainActor class Coordinator: NSObject { var parent: NSPopUpButtonView @@ -150,7 +150,11 @@ struct FindModePicker: View { .sink { [weak self] notification in if let menuItem = notification.userInfo?["MenuItem"] as? NSMenuItem, let selection = menuItem as? ItemType { - self?.parent.selection = selection + // AppKit posts `NSMenu.didSendActionNotification` on the main thread, + // so this delivery is main-thread and the binding may be written here. + MainActor.assumeIsolated { + self?.parent.selection = selection + } } } } diff --git a/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorConfiguration.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorConfiguration.swift new file mode 100644 index 0000000000..8ae899df26 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorConfiguration.swift @@ -0,0 +1,22 @@ +// +// FindNavigatorConfiguration.swift +// Search +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import Foundation + +/// Layout preferences supplied by the app (derived from Settings, which the +/// Search package cannot import). +public struct FindNavigatorConfiguration: Equatable, Sendable { + /// Row height for file rows and minimum height for match rows. + public var rowHeight: Double + /// Maximum number of preview lines for a match row. + public var matchDetailLineLimit: Int + + public init(rowHeight: Double, matchDetailLineLimit: Int) { + self.rowHeight = rowHeight + self.matchDetailLineLimit = matchDetailLineLimit + } +} diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorForm.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorForm.swift index bb10b029b2..77127b4797 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorForm.swift @@ -1,14 +1,16 @@ // -// SearchModeSelector.swift +// FindNavigatorForm.swift // CodeEdit // // Created by Ziyuan Zhao on 2022/3/21. // import SwiftUI +import CodeEditUI +import CodeEditCore struct FindNavigatorForm: View { - @ObservedObject private var state: WorkspaceDocument.SearchState + @ObservedObject private var state: SearchState @State private var selectedMode: [SearchModeModel] { didSet { @@ -27,7 +29,7 @@ struct FindNavigatorForm: View { @State private var excludeSettings: Bool = true @FocusState private var isSearchFieldFocused: Bool - init(state: WorkspaceDocument.SearchState) { + init(state: SearchState) { self.state = state selectedMode = state.selectedMode } diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorIndexBar.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorIndexBar.swift similarity index 90% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorIndexBar.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorIndexBar.swift index c7c903d359..c0c1c6d0bc 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorIndexBar.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorIndexBar.swift @@ -8,11 +8,11 @@ import SwiftUI struct FindNavigatorIndexBar: View { - @ObservedObject private var state: WorkspaceDocument.SearchState + @ObservedObject private var state: SearchState @State private var progress: Double = 0.0 @State private var shouldShow: Bool = false - init(state: WorkspaceDocument.SearchState) { + init(state: SearchState) { self.state = state } @@ -45,7 +45,7 @@ struct FindNavigatorIndexBar: View { /// Updates the bar with a new status update. /// - Parameter status: The new status. - private func updateWithNewStatus(_ status: WorkspaceDocument.SearchState.IndexStatus) { + private func updateWithNewStatus(_ status: SearchState.IndexStatus) { switch status { case .none: self.progress = 0.0 diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift similarity index 91% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift index dfec82a1b0..7f05dd11f5 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift @@ -6,16 +6,19 @@ // import SwiftUI +import CodeEditCore final class FindNavigatorListViewController: NSViewController { - public var workspace: WorkspaceDocument + private let fileOpener: WorkspaceFileOpener + + var configuration: FindNavigatorConfiguration + public var selectedItem: Any? private var searchItems: [SearchResultModel] = [] private var scrollView: NSScrollView! private var outlineView: NSOutlineView! - private let prefs = Settings.shared.preferences private var collapsedRows: Set = [] var rowHeight: Double = 22 { @@ -44,8 +47,9 @@ final class FindNavigatorListViewController: NSViewController { self.scrollView.contentView.contentInsets = .init(top: 0, left: 0, bottom: 0, right: 0) } - init(workspace: WorkspaceDocument) { - self.workspace = workspace + init(configuration: FindNavigatorConfiguration, fileOpener: WorkspaceFileOpener) { + self.configuration = configuration + self.fileOpener = fileOpener super.init(nibName: nil, bundle: nil) } @@ -168,17 +172,14 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: outlineView.rowHeight) return FindNavigatorListMatchCell(frame: frameRect, matchItem: item) } else { + guard let file = (item as? SearchResultModel)?.file else { return nil } let frameRect = NSRect( x: 0, y: 0, width: tableColumn.width, - height: prefs.general.projectNavigatorSize.rowHeight - ) - let view = ProjectNavigatorTableViewCell( - frame: frameRect, - item: (item as? SearchResultModel)?.file, - isEditable: false + height: configuration.rowHeight ) + let view = SearchResultFileCell(frame: frameRect, file: file, rowHeight: configuration.rowHeight) // We're using a medium label for file names b/c it makes it easier to // distinguish quickly which results are from which files. view.textField?.font = .systemFont(ofSize: 13, weight: .medium) @@ -197,13 +198,13 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { let selectedMatch = self.selectedItem as? SearchResultMatchModel if selectedItem == nil || selectedMatch != item { self.selectedItem = item - workspace.editorManager?.openTab(item: item.file) + fileOpener.openFile(at: item.file.url) } } else if let item = outlineView.item(atRow: selectedIndex) as? SearchResultModel { let selectedFile = self.selectedItem as? SearchResultModel if selectedItem == nil || selectedFile != item { self.selectedItem = item - workspace.editorManager?.openTab(item: item.file) + fileOpener.openFile(at: item.file.url) } } } @@ -222,7 +223,7 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { guard availableWidth > 0 else { // Not enough space to display anything, return minimum height - return max(rowHeight, Settings.shared.preferences.general.projectNavigatorSize.rowHeight) + return max(rowHeight, configuration.rowHeight) } let attributedString = matchItem.attributedLabel() @@ -239,7 +240,7 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { tempView.cell?.wraps = true tempView.cell?.usesSingleLineMode = false tempView.lineBreakMode = .byWordWrapping - tempView.maximumNumberOfLines = Settings.shared.preferences.general.findNavigatorDetail.rawValue + tempView.maximumNumberOfLines = configuration.matchDetailLineLimit tempView.preferredMaxLayoutWidth = availableWidth var calculatedHeight = tempView.sizeThatFits( @@ -252,7 +253,7 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { return max(calculatedHeight, self.rowHeight) } // For parent items - return prefs.general.projectNavigatorSize.rowHeight + return configuration.rowHeight } func outlineViewColumnDidResize(_ notification: Notification) { diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorMatchListCell.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorMatchListCell.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorMatchListCell.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorMatchListCell.swift index 1f60f23bb2..6a86d4aab2 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorMatchListCell.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorMatchListCell.swift @@ -1,5 +1,5 @@ // -// FindNavigatorListCell.swift +// FindNavigatorMatchListCell.swift // CodeEdit // // Created by Khan Winter on 7/7/22. diff --git a/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorResultList.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorResultList.swift new file mode 100644 index 0000000000..2ff14cb8e5 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/FindNavigatorResultList.swift @@ -0,0 +1,67 @@ +// +// FindNavigatorResultList.swift +// CodeEdit +// +// Created by Ziyuan Zhao on 2022/3/22. +// + +import SwiftUI +import Combine + +struct FindNavigatorResultList: NSViewControllerRepresentable { + + @EnvironmentObject var state: SearchState + @Environment(\.workspaceFileOpener) + private var fileOpener + + let configuration: FindNavigatorConfiguration + + typealias NSViewControllerType = FindNavigatorListViewController + + func makeNSViewController(context: Context) -> FindNavigatorListViewController { + let controller = FindNavigatorListViewController(configuration: configuration, fileOpener: fileOpener) + controller.setSearchResults(state.searchResult) + controller.rowHeight = configuration.rowHeight + context.coordinator.controller = controller + return controller + } + + func updateNSViewController(_ nsViewController: FindNavigatorListViewController, context: Context) { + nsViewController.updateNewSearchResults(state.searchResult) + if nsViewController.configuration != configuration { + nsViewController.configuration = configuration + nsViewController.rowHeight = configuration.rowHeight + } + return + } + + func makeCoordinator() -> Coordinator { + Coordinator( + state: state, + controller: nil + ) + } + + @MainActor + class Coordinator: NSObject { + init(state: SearchState?, controller: FindNavigatorListViewController?) { + self.controller = controller + super.init() + self.listener = state? + .$searchResult + .sink(receiveValue: { [weak self] searchResults in + // `searchResult` is only mutated on the main actor (`setSearchResults` + // is @MainActor; `clearResults` hops to main), so delivery is main-thread. + MainActor.assumeIsolated { + self?.controller?.updateNewSearchResults(searchResults) + } + }) + } + + var listener: AnyCancellable? + var controller: FindNavigatorListViewController? + + // No explicit deinit: `AnyCancellable` cancels its subscription automatically + // on deallocation, and a nonisolated deinit may not touch main-actor state. + } +} diff --git a/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/SearchResultFileCell.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/SearchResultFileCell.swift new file mode 100644 index 0000000000..2a842fb0ba --- /dev/null +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorResultList/SearchResultFileCell.swift @@ -0,0 +1,41 @@ +// +// SearchResultFileCell.swift +// Search +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import AppKit +import CodeEditCore + +/// File-row cell for the find navigator's result list. +/// Owned by the Search package so it renders from `SearchResultFile` +/// (name + system file icon) without the project navigator's cell. +final class SearchResultFileCell: NSTableCellView { + init(frame frameRect: NSRect, file: SearchResultFile, rowHeight: Double) { + super.init(frame: frameRect) + + let icon = NSImageView(frame: NSRect(x: 2, y: 0, width: rowHeight, height: frameRect.height)) + icon.image = NSWorkspace.shared.icon(forFile: file.url.path) + icon.symbolConfiguration = .init(pointSize: rowHeight * 0.64, weight: .regular) + addSubview(icon) + imageView = icon + + let label = NSTextField(labelWithString: file.name) + label.frame = NSRect( + x: icon.frame.maxX + 4, + y: (frameRect.height - 17) / 2, + width: frameRect.width - icon.frame.maxX - 8, + height: 17 + ) + label.font = .systemFont(ofSize: NSFont.systemFontSize(for: .regular)) + label.lineBreakMode = .byTruncatingTail + addSubview(label) + textField = label + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorToolbarBottom.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorToolbarBottom.swift similarity index 94% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorToolbarBottom.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorToolbarBottom.swift index 7bf248dffa..306627f0e0 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorToolbarBottom.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorToolbarBottom.swift @@ -1,11 +1,12 @@ // -// SourceControlToolbarBottom.swift +// FindNavigatorToolbarBottom.swift // CodeEdit // // Created by Nanashi Li on 2022/05/20. // import SwiftUI +import CodeEditUI struct FindNavigatorToolbarBottom: View { @State private var text = "" diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorView.swift b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorView.swift similarity index 85% rename from CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorView.swift rename to CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorView.swift index 58e211412d..3ea08ef501 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorView.swift +++ b/CodeEditModules/Sources/CESearch/FindNavigator/FindNavigatorView.swift @@ -6,20 +6,23 @@ // import SwiftUI +import CodeEditUI -struct FindNavigatorView: View { - @EnvironmentObject private var workspace: WorkspaceDocument +public struct FindNavigatorView: View { + @EnvironmentObject private var state: SearchState - private var state: WorkspaceDocument.SearchState { - workspace.searchState ?? .init(workspace) - } + private let configuration: FindNavigatorConfiguration @State private var foundFilesCount: Int = 0 @State private var searchResultCount: Int = 0 - @State private var findNavigatorStatus: WorkspaceDocument.SearchState.FindNavigatorStatus = .none + @State private var findNavigatorStatus: SearchState.FindNavigatorStatus = .none @State private var findResultMessage: String? - var body: some View { + public init(configuration: FindNavigatorConfiguration) { + self.configuration = configuration + } + + public var body: some View { VStack { VStack { FindNavigatorForm(state: state) @@ -70,7 +73,7 @@ struct FindNavigatorView: View { systemImage: "exclamationmark.magnifyingglass" ) } else { - FindNavigatorResultList() + FindNavigatorResultList(configuration: configuration) } case .replaced(let updatedFiles): CEContentUnavailableView( diff --git a/CodeEditModules/Sources/CESearch/FindNavigatorContribution.swift b/CodeEditModules/Sources/CESearch/FindNavigatorContribution.swift new file mode 100644 index 0000000000..2c059881d5 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/FindNavigatorContribution.swift @@ -0,0 +1,50 @@ +// +// FindNavigatorContribution.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import CodeEditSettings +import CodeEditUI +import SwiftUI + +/// CESearch's navigator tab. +/// +/// The package vends this itself; there is no app-side wrapper. Settings are read here through the +/// seam rather than passed down from the app, which is what the wrapper this replaced existed to do +/// before feature packages could read settings. +public struct FindNavigatorContribution: WorkspacePanelContribution { + /// The single source of truth for this tab's id. The app-side `PanelTabID.search` references + /// this constant so there is exactly one place the id is defined, even though ownership of the + /// id now sits with the feature that owns the tab. + public static let tabID = "search" + + public let id = FindNavigatorContribution.tabID + public let title = "Search" + public let systemImage = "magnifyingglass" + + public init() {} + + public var content: AnyView { AnyView(FindNavigatorContentView()) } + + public var bottomView: AnyView? { AnyView(FindNavigatorToolbarBottom()) } +} + +/// Reads the settings the find navigator needs, so the contribution itself stays a plain value. +private struct FindNavigatorContentView: View { + @SettingsValue(GeneralSettings.self, \.projectNavigatorSize) + private var projectNavigatorSize + + @SettingsValue(GeneralSettings.self, \.findNavigatorDetail) + private var findNavigatorDetail + + var body: some View { + FindNavigatorView( + configuration: FindNavigatorConfiguration( + rowHeight: projectNavigatorSize.rowHeight, + matchDetailLineLimit: findNavigatorDetail.rawValue + ) + ) + } +} diff --git a/CodeEdit/Features/Documents/Indexer/AsyncFileIterator.swift b/CodeEditModules/Sources/CESearch/Indexer/AsyncFileIterator.swift similarity index 74% rename from CodeEdit/Features/Documents/Indexer/AsyncFileIterator.swift rename to CodeEditModules/Sources/CESearch/Indexer/AsyncFileIterator.swift index f45134d1ec..97c9878956 100644 --- a/CodeEdit/Features/Documents/Indexer/AsyncFileIterator.swift +++ b/CodeEditModules/Sources/CESearch/Indexer/AsyncFileIterator.swift @@ -9,14 +9,18 @@ import Foundation /// Given a list of file URLs, asynchronously fetches their contents and returns them iteratively. /// Returns files as a ``SearchIndexer/AsyncManager/TextFile`` struct, used to index workspaces. -struct AsyncFileIterator: AsyncSequence, AsyncIteratorProtocol { - typealias TextFile = SearchIndexer.AsyncManager.TextFile - typealias Element = (TextFile, Int) +public struct AsyncFileIterator: AsyncSequence, AsyncIteratorProtocol { + public typealias TextFile = SearchIndexer.AsyncManager.TextFile + public typealias Element = (TextFile, Int) let fileURLs: [URL] var currentIdx = 0 - mutating func next() async -> Element? { + public init(fileURLs: [URL]) { + self.fileURLs = fileURLs + } + + public mutating func next() async -> Element? { guard !Task.isCancelled else { return nil } @@ -42,7 +46,7 @@ struct AsyncFileIterator: AsyncSequence, AsyncIteratorProtocol { return (foundContent!, currentIdx) } - func makeAsyncIterator() -> AsyncFileIterator { + public func makeAsyncIterator() -> AsyncFileIterator { self } } diff --git a/CodeEdit/Features/Documents/Indexer/FileHelper.swift b/CodeEditModules/Sources/CESearch/Indexer/FileHelper.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/FileHelper.swift rename to CodeEditModules/Sources/CESearch/Indexer/FileHelper.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+Add.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Add.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+Add.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Add.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+AsyncController.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+AsyncController.swift similarity index 89% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+AsyncController.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+AsyncController.swift index 885573763e..f8d1e0147e 100644 --- a/CodeEdit/Features/Documents/Indexer/SearchIndexer+AsyncController.swift +++ b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+AsyncController.swift @@ -9,26 +9,29 @@ import Foundation extension SearchIndexer { /// Manager for SearchIndexer object that supports async calls to the index - class AsyncManager { + public class AsyncManager { /// An instance of the SearchIndexer - let index: SearchIndexer + public let index: SearchIndexer private let addQueue = DispatchQueue(label: "app.codeedit.CodeEdit.AddFilesToIndex", attributes: .concurrent) private let searchQueue = DispatchQueue(label: "app.codeedit.CodeEdit.SearchIndex", attributes: .concurrent) - init(index: SearchIndexer) { + /// Create an async manager wrapping an index. + /// + /// - Parameter index: The index to perform asynchronous operations on. + public init(index: SearchIndexer) { self.index = index } - class TextFile { - let url: URL - let text: String + public final class TextFile: Sendable { + public let url: URL + public let text: String /// Create a text async task /// /// - Parameters: /// - url: the identifying document URL /// - text: The text to add to the index - init(url: URL, text: String) { + public init(url: URL, text: String) { self.url = url self.text = text } @@ -61,7 +64,7 @@ extension SearchIndexer { /// print(result) /// } /// ``` - func search( + public func search( query: String, _ maxResults: Int, timeout: TimeInterval = 1.0 @@ -89,7 +92,7 @@ extension SearchIndexer { /// the index when the operation is complete. Default is `false`. /// /// - Returns: An array of booleans indicating the success of adding each file to the index. - func addText( + public func addText( files: [TextFile], flushWhenComplete: Bool = false ) async -> [Bool] { @@ -99,9 +102,9 @@ extension SearchIndexer { // Asynchronously iterate through the provided files using a task group await withTaskGroup(of: Bool.self) { taskGroup in for file in files { - taskGroup.addTask { + taskGroup.addTask { [index] in // Add the file to the index and return the success status - return self.index.addFileWithText(file.url, text: file.text, canReplace: true) + return index.addFileWithText(file.url, text: file.text, canReplace: true) } } @@ -127,7 +130,7 @@ extension SearchIndexer { /// - Returns: An array of booleans indicating the success of adding each file to the index. /// - Warning: Prefer using `addText` when possible as SearchKit does not have the ability /// to read every file type. For example, it is often not possible to read Swift files. - func addFiles( + public func addFiles( urls: [URL], flushWhenComplete: Bool = false ) async -> [Bool] { @@ -135,8 +138,8 @@ extension SearchIndexer { await withTaskGroup(of: Bool.self) { taskGroup in for url in urls { - taskGroup.addTask { - return self.index.addFile(fileURL: url, canReplace: true) + taskGroup.addTask { [index] in + return index.addFile(fileURL: url, canReplace: true) } } diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+File.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+File.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+File.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+File.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+InternalMethods.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+InternalMethods.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+InternalMethods.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+InternalMethods.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+Memory.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Memory.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+Memory.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Memory.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+ProgressiveSearch.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+ProgressiveSearch.swift similarity index 96% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+ProgressiveSearch.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+ProgressiveSearch.swift index db06a8d0b6..887b445922 100644 --- a/CodeEdit/Features/Documents/Indexer/SearchIndexer+ProgressiveSearch.swift +++ b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+ProgressiveSearch.swift @@ -9,12 +9,12 @@ import Foundation extension SearchIndexer { /// Object representing the search results - public class SearchResult { + public final class SearchResult: Sendable { /// The identifying url for the document - let url: URL + public let url: URL /// The search score for the document, higher means more relevant - let score: Float + public let score: Float init(url: URL, score: Float) { self.url = url @@ -34,7 +34,7 @@ extension SearchIndexer { /// A search starts on creation and can be cancelled at any time. public class ProgressiveSearch { /// A class representing the results of a search request. - public class Results { + public final class Results: Sendable { /// Create a search result /// /// - Parameters: diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+Search.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Search.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+Search.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Search.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer+Terms.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Terms.swift similarity index 100% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer+Terms.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer+Terms.swift diff --git a/CodeEdit/Features/Documents/Indexer/SearchIndexer.swift b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer.swift similarity index 96% rename from CodeEdit/Features/Documents/Indexer/SearchIndexer.swift rename to CodeEditModules/Sources/CESearch/Indexer/SearchIndexer.swift index 5b2150c6fa..221034d981 100644 --- a/CodeEdit/Features/Documents/Indexer/SearchIndexer.swift +++ b/CodeEditModules/Sources/CESearch/Indexer/SearchIndexer.swift @@ -8,7 +8,10 @@ import Foundation /// Indexer using SKIndex -public class SearchIndexer { +/// +/// `@unchecked Sendable`: SearchKit is documented thread-safe, and all index +/// mutations are serialized on `modifyIndexQueue`. +public class SearchIndexer: @unchecked Sendable { let modifyIndexQueue = DispatchQueue(label: "app.codeedit.CodeEdit.ModifySearchIndex") var index: SKIndex? diff --git a/CodeEdit/Features/Search/Model/SearchModeModel.swift b/CodeEditModules/Sources/CESearch/Model/SearchModeModel.swift similarity index 50% rename from CodeEdit/Features/Search/Model/SearchModeModel.swift rename to CodeEditModules/Sources/CESearch/Model/SearchModeModel.swift index 007651727c..e9c8e0f71d 100644 --- a/CodeEdit/Features/Search/Model/SearchModeModel.swift +++ b/CodeEditModules/Sources/CESearch/Model/SearchModeModel.swift @@ -8,75 +8,87 @@ import Foundation // TODO: DOCS (Ziyuan Zhao) -struct SearchModeModel: Hashable { - let title: String - let children: [SearchModeModel] - let needSelectionHighlight: Bool +public struct SearchModeModel: Hashable, Sendable { + public let title: String + public let children: [SearchModeModel] + public let needSelectionHighlight: Bool - static let Containing = SearchModeModel(title: "Containing", children: [], needSelectionHighlight: false) - static let MatchingWord = SearchModeModel( + public init(title: String, children: [SearchModeModel], needSelectionHighlight: Bool) { + self.title = title + self.children = children + self.needSelectionHighlight = needSelectionHighlight + } + + public static let Containing = SearchModeModel(title: "Containing", children: [], needSelectionHighlight: false) + public static let MatchingWord = SearchModeModel( title: "Matching Word", children: [], needSelectionHighlight: true ) - static let StartingWith = SearchModeModel( + public static let StartingWith = SearchModeModel( title: "Starting With", children: [], needSelectionHighlight: true ) - static let EndingWith = SearchModeModel(title: "Ending With", children: [], needSelectionHighlight: true) + public static let EndingWith = SearchModeModel( + title: "Ending With", + children: [], + needSelectionHighlight: true + ) - static let Text = SearchModeModel( + public static let Text = SearchModeModel( title: "Text", children: [.Containing, .MatchingWord, .StartingWith, .EndingWith], needSelectionHighlight: false ) - static let References = SearchModeModel( + public static let References = SearchModeModel( title: "References", children: [.Containing, .MatchingWord, .StartingWith, .EndingWith], needSelectionHighlight: true ) - static let Definitions = SearchModeModel( + public static let Definitions = SearchModeModel( title: "Definitions", children: [.Containing, .MatchingWord, .StartingWith, .EndingWith], needSelectionHighlight: true ) - static let RegularExpression = SearchModeModel( + public static let RegularExpression = SearchModeModel( title: "Regular Expression", children: [], needSelectionHighlight: true ) - static let CallHierarchy = SearchModeModel( + public static let CallHierarchy = SearchModeModel( title: "Call Hierarchy", children: [], needSelectionHighlight: true ) - static let Find = SearchModeModel( + public static let Find = SearchModeModel( title: "Find", children: [.Text, .References, .Definitions, .RegularExpression, .CallHierarchy], needSelectionHighlight: false ) - static let Replace = SearchModeModel( + public static let Replace = SearchModeModel( title: "Replace", children: [.Text, .RegularExpression], needSelectionHighlight: true ) - static let TextMatchingModes: [SearchModeModel] = [.Containing, .MatchingWord, .StartingWith, .EndingWith] - static let FindModes: [SearchModeModel] = [ + public static let TextMatchingModes: [SearchModeModel] = [ + .Containing, .MatchingWord, .StartingWith, .EndingWith + ] + public static let FindModes: [SearchModeModel] = [ .Text, .References, .Definitions, .RegularExpression, .CallHierarchy ] - static let ReplaceModes: [SearchModeModel] = [.Text, .RegularExpression] - static let SearchModes: [SearchModeModel] = [.Find, .Replace] + public static let ReplaceModes: [SearchModeModel] = [.Text, .RegularExpression] + public static let SearchModes: [SearchModeModel] = [.Find, .Replace] } extension SearchModeModel: Equatable { - static func == (lhs: SearchModeModel, rhs: SearchModeModel) -> Bool { + public static func == (lhs: SearchModeModel, rhs: SearchModeModel) -> Bool { lhs.title == rhs.title && lhs.children == rhs.children && lhs.needSelectionHighlight == rhs.needSelectionHighlight diff --git a/CodeEditModules/Sources/CESearch/Model/SearchResultFile.swift b/CodeEditModules/Sources/CESearch/Model/SearchResultFile.swift new file mode 100644 index 0000000000..dd4a501a59 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/Model/SearchResultFile.swift @@ -0,0 +1,22 @@ +// +// SearchResultFile.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import Foundation + +/// A lightweight, framework-free representation of a file appearing in search results. +/// Search never needs the full app file model — only identity, location, and a display name. +public struct SearchResultFile: Identifiable, Hashable, Sendable { + public let id: String + public let url: URL + public let name: String + + public init(url: URL, name: String? = nil) { + self.id = url.absoluteString + self.url = url + self.name = name ?? url.lastPathComponent + } +} diff --git a/CodeEdit/Features/Search/Model/SearchResultMatchModel.swift b/CodeEditModules/Sources/CESearch/Model/SearchResultMatchModel.swift similarity index 82% rename from CodeEdit/Features/Search/Model/SearchResultMatchModel.swift rename to CodeEditModules/Sources/CESearch/Model/SearchResultMatchModel.swift index 1ad0b68102..93598eaa4b 100644 --- a/CodeEdit/Features/Search/Model/SearchResultMatchModel.swift +++ b/CodeEditModules/Sources/CESearch/Model/SearchResultMatchModel.swift @@ -1,5 +1,5 @@ // -// SearchResultLineMatchModel.swift +// SearchResultMatchModel.swift // CodeEditModules/Search // // Created by Khan Winter on 7/6/22. @@ -7,12 +7,13 @@ import Foundation import Cocoa +import CodeEditCore /// A struct for holding information about a search match. -class SearchResultMatchModel: Hashable, Identifiable { - init( +public class SearchResultMatchModel: Hashable, Identifiable { + public init( rangeWithinFile: Range, - file: CEWorkspaceFile, + file: SearchResultFile, lineContent: String, keywordRange: Range ) { @@ -23,13 +24,13 @@ class SearchResultMatchModel: Hashable, Identifiable { self.keywordRange = keywordRange } - var id: UUID - var file: CEWorkspaceFile - var rangeWithinFile: Range - var lineContent: String - var keywordRange: Range + public var id: UUID + public var file: SearchResultFile + public var rangeWithinFile: Range + public var lineContent: String + public var keywordRange: Range - static func == (lhs: SearchResultMatchModel, rhs: SearchResultMatchModel) -> Bool { + public static func == (lhs: SearchResultMatchModel, rhs: SearchResultMatchModel) -> Bool { return lhs.id == rhs.id && lhs.file == rhs.file && lhs.rangeWithinFile == rhs.rangeWithinFile @@ -37,7 +38,7 @@ class SearchResultMatchModel: Hashable, Identifiable { && lhs.keywordRange == rhs.keywordRange } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(file) hasher.combine(rangeWithinFile) @@ -48,7 +49,7 @@ class SearchResultMatchModel: Hashable, Identifiable { /// Returns a formatted `NSAttributedString` with the search result bolded. /// Will only return 60 characters before and after the matched result. /// - Returns: The formatted `NSAttributedString` - func attributedLabel() -> NSAttributedString { + public func attributedLabel() -> NSAttributedString { // By default `NSTextView` will ignore any paragraph wrapping set to the label when it's // using an `NSAttributedString` so we need to set the wrap mode here. let paragraphStyle = NSMutableParagraphStyle() diff --git a/CodeEdit/Features/Search/Model/SearchResultModel.swift b/CodeEditModules/Sources/CESearch/Model/SearchResultModel.swift similarity index 67% rename from CodeEdit/Features/Search/Model/SearchResultModel.swift rename to CodeEditModules/Sources/CESearch/Model/SearchResultModel.swift index 92de452e5c..8a26d8010c 100644 --- a/CodeEdit/Features/Search/Model/SearchResultModel.swift +++ b/CodeEditModules/Sources/CESearch/Model/SearchResultModel.swift @@ -6,19 +6,20 @@ // import Foundation +import CodeEditCore /// A struct for holding information about a file and any matches it may have for a search query. -class SearchResultModel: Hashable { +public class SearchResultModel: Hashable { - var file: CEWorkspaceFile + public var file: SearchResultFile // The score represents how well the file matches the search query. // The higher the score is, the better the file matches the search query. // The score is assign by Search Kit. - var score: Float - var lineMatches: [SearchResultMatchModel] + public var score: Float + public var lineMatches: [SearchResultMatchModel] - init( - file: CEWorkspaceFile, + public init( + file: SearchResultFile, score: Float, lineMatches: [SearchResultMatchModel] = [] ) { @@ -27,12 +28,12 @@ class SearchResultModel: Hashable { self.lineMatches = lineMatches } - static func == (lhs: SearchResultModel, rhs: SearchResultModel) -> Bool { + public static func == (lhs: SearchResultModel, rhs: SearchResultModel) -> Bool { return lhs.file == rhs.file && lhs.lineMatches == rhs.lineMatches } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(file) hasher.combine(lineMatches) } diff --git a/CodeEditModules/Sources/CESearch/SearchState/SearchState+Find.swift b/CodeEditModules/Sources/CESearch/SearchState/SearchState+Find.swift new file mode 100644 index 0000000000..e680ec3da0 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/SearchState/SearchState+Find.swift @@ -0,0 +1,115 @@ +// +// SearchState+Find.swift +// CodeEdit +// +// Created by Tommy Ludwig on 02.01.24. +// + +import Foundation + +extension SearchState: @unchecked Sendable {} + +extension SearchState { + /// Searches the entire workspace for the given string, using the + /// ``Workspace/SearchState-swift.class/selectedMode`` modifiers + /// to modify the search if needed. This is done by filtering out files with SearchKit and then searching + /// within each file for the given string. + /// + /// This method will update + /// ``Workspace/SearchState-swift.class/searchResult``, + /// ``Workspace/SearchState-swift.class/searchResultsFileCount`` + /// and ``Workspace/SearchState-swift.class/searchResultCount`` with any matched + /// search results. See ``SearchResultModel`` and ``SearchResultMatchModel`` + /// for more information on search results and matches. + /// + /// - Parameter query: The search query to search for. + public func search(_ query: String) async { + clearResults() + + await MainActor.run { + self.searchQuery = query + self.findNavigatorStatus = .searching + } + + let searchQuery = getSearchTerm(query) + let regexPattern = getRegexPattern(query) + + guard let indexer = indexer else { + await setStatus(.failed(errorMessage: "No index found. Try rebuilding the index.")) + return + } + + let asyncController = SearchIndexer.AsyncManager(index: indexer) + let evaluateResultGroup = DispatchGroup() + let evaluateSearchQueue = DispatchQueue(label: "app.codeedit.CodeEdit.EvaluateSearch") + + let searchStream = await asyncController.search(query: searchQuery, 20) + for try await result in searchStream { + for file in result.results { + let fileURL = file.url + let fileScore = file.score + let capturedRegexPattern = regexPattern + + evaluateSearchQueue.async(group: evaluateResultGroup) { + evaluateResultGroup.enter() + Task { [weak self] in + guard let self else { + evaluateResultGroup.leave() + return + } + + let result = await self.evaluateSearchResult( + fileURL: fileURL, + fileScore: fileScore, + regexPattern: capturedRegexPattern + ) + + if let result = result { + await self.appendNewResultsToTempResults(newResult: result) + } + evaluateResultGroup.leave() + } + } + } + } + + evaluateResultGroup.notify(queue: evaluateSearchQueue) { + Task { @MainActor [weak self] in + self?.setSearchResults() + } + } + } + + /// Appends a new search result to the temporary search results array on the main thread. + /// + /// - Parameters: + /// - newResult: The `SearchResultModel` to be appended to the temporary search results. + @MainActor + func appendNewResultsToTempResults(newResult: SearchResultModel) { + self.tempSearchResults.append(newResult) + } + + /// Sets the search results by updating various properties on the main thread. + /// This function updates `findNavigatorStatus`, `searchResult`, `searchResultCount`, and `searchResultsFileCount` + /// and sets the `tempSearchResults` to an empty array. + /// - Important: Call this function when you are ready to + /// display or use the final search results. + @MainActor + func setSearchResults() { + self.searchResult = self.tempSearchResults.sorted { $0.score > $1.score } + self.searchResultsCount = self.tempSearchResults.map { $0.lineMatches.count }.reduce(0, +) + self.searchResultsFileCount = self.tempSearchResults.count + self.findNavigatorStatus = .found + self.tempSearchResults = [] + } + + /// Resets the search results along with counts for overall results and file-specific results. + public func clearResults() { + DispatchQueue.main.async { + self.searchResult.removeAll() + self.searchResultsCount = 0 + self.searchResultsFileCount = 0 + self.findNavigatorStatus = .none + } + } +} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+FindAndReplace.swift b/CodeEditModules/Sources/CESearch/SearchState/SearchState+FindAndReplace.swift similarity index 64% rename from CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+FindAndReplace.swift rename to CodeEditModules/Sources/CESearch/SearchState/SearchState+FindAndReplace.swift index 48ed02b8ce..f7f36f4a6d 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+FindAndReplace.swift +++ b/CodeEditModules/Sources/CESearch/SearchState/SearchState+FindAndReplace.swift @@ -1,14 +1,13 @@ // -// WorkspaceDocument+FindAndReplace.swift +// SearchState+FindAndReplace.swift // CodeEdit // // Created by Tommy Ludwig on 02.01.24. // import Foundation -import AppKit -extension WorkspaceDocument.SearchState { +extension SearchState { /// Performs a search and replace operation in a collection of files based on the provided query. /// /// - Parameters: @@ -18,7 +17,7 @@ extension WorkspaceDocument.SearchState { /// - Important: This function relies on an indexer and assumes that it has been previously set. /// If the indexer is not available, the function will return early. /// Also make sure to flush any pending changes to the index before calling this function. - func findAndReplace(query: String, replacingTerm: String) async throws { + public func findAndReplace(query: String, replacingTerm: String) async throws { await setStatus(.replacing) let searchQuery = getSearchTerm(query) guard let indexer = indexer else { return } @@ -107,63 +106,6 @@ extension WorkspaceDocument.SearchState { try updatedContent.write(to: fileURL, atomically: true, encoding: .utf8) } - /// Replaces a specified range of text within a file with a new string. - /// - /// - Parameters: - /// - file: The URL of the file to be modified. - /// - searchTerm: The string to be replaced within the specified range. - /// - replacingTerm: The string to replace the specified searchTerm. - /// - keywordRange: The range within which the replacement should occur. - /// - /// - Note: This function can be utilised for two specific use cases: - /// 1. To replace a particular occurrence of a string within a file, - /// provide the range of the keyword to be replaced. - /// 2. To replace all occurrences of the string within the file, - /// pass the start and end index covering the entire range. - func replaceRange( - file: URL, - searchTerm: String, - replacingTerm: String, - keywordRange: Range - ) { - guard let fileContent = try? String(contentsOf: file, encoding: .utf8) else { - let alert = NSAlert() - alert.messageText = "Error" - alert.informativeText = "An error occurred while reading file contents of: \(file)" - alert.alertStyle = .critical - alert.addButton(withTitle: "OK") - alert.runModal() - - return - } - - var replaceOptions = NSString.CompareOptions() - if selectedMode.second == .RegularExpression { - replaceOptions = [.regularExpression] - } - if !caseSensitive { - replaceOptions = [.caseInsensitive] - } - - let updatedContent = fileContent.replacingOccurrences( - of: searchTerm, - with: replacingTerm, - options: replaceOptions, - range: keywordRange - ) - - do { - try updatedContent.write(to: file, atomically: true, encoding: .utf8) - } catch { - let alert = NSAlert() - alert.messageText = "Error" - alert.informativeText = "An error occurred while writing to: \(error.localizedDescription)" - alert.alertStyle = .critical - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - func setStatus(_ status: FindNavigatorStatus) async { await MainActor.run { self.findNavigatorStatus = status diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift b/CodeEditModules/Sources/CESearch/SearchState/SearchState+Index.swift similarity index 65% rename from CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift rename to CodeEditModules/Sources/CESearch/SearchState/SearchState+Index.swift index e84edceaf7..e82d16a013 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift +++ b/CodeEditModules/Sources/CESearch/SearchState/SearchState+Index.swift @@ -1,29 +1,31 @@ // -// WorkspaceDocument+Index.swift +// SearchState+Index.swift // CodeEdit // // Created by Tommy Ludwig on 02.01.24. // import Foundation +import CodeEditCore -extension WorkspaceDocument.SearchState { +extension SearchState { /// Adds the contents of the current workspace URL to the search index. /// That means that the contents of the workspace will be indexed and searchable. func addProjectToIndex() { guard let indexer = indexer else { return } - guard let url = workspace.fileURL else { return } + let url = workspaceURL indexStatus = .indexing(progress: 0.0) let uuidString = UUID().uuidString - let createInfo: [String: Any] = [ - "id": uuidString, - "action": "create", - "title": "Indexing | Processing files", - "message": "Creating an index to enable fast and accurate searches within your codebase.", - "isLoading": true - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createInfo) + let eventBus = eventBus + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel( + id: uuidString, + title: "Indexing | Processing files", + message: "Creating an index to enable fast and accurate searches within your codebase.", + isLoading: true + )) + )) Task.detached { let filePaths = self.getFileURLs(at: url) @@ -41,12 +43,9 @@ extension WorkspaceDocument.SearchState { await MainActor.run { self.indexStatus = .indexing(progress: progress) } - let updateInfo: [String: Any] = [ - "id": uuidString, - "action": "update", - "percentage": progress - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) + eventBus.publish(TaskNotificationEvent( + .update(id: uuidString, percentage: progress) + )) } } asyncController.index.flush() @@ -54,20 +53,13 @@ extension WorkspaceDocument.SearchState { await MainActor.run { self.indexStatus = .done } - let updateInfo: [String: Any] = [ - "id": uuidString, - "action": "update", - "title": "Finished indexing", - "isLoading": false - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) + eventBus.publish(TaskNotificationEvent( + .update(id: uuidString, title: "Finished indexing", isLoading: false) + )) - let deleteInfo = [ - "id": uuidString, - "action": "deleteWithDelay", - "delay": 4.0 - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) + eventBus.publish(TaskNotificationEvent( + .deleteWithDelay(id: uuidString, delay: 4.0) + )) } } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Find.swift b/CodeEditModules/Sources/CESearch/SearchState/SearchState+MatchExtraction.swift similarity index 57% rename from CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Find.swift rename to CodeEditModules/Sources/CESearch/SearchState/SearchState+MatchExtraction.swift index 159a4bf0d7..2a8ba641fd 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Find.swift +++ b/CodeEditModules/Sources/CESearch/SearchState/SearchState+MatchExtraction.swift @@ -1,170 +1,37 @@ // -// WorkspaceDocument+Find.swift +// SearchState+MatchExtraction.swift // CodeEdit // // Created by Tommy Ludwig on 02.01.24. // import Foundation +import CodeEditCore -extension WorkspaceDocument.SearchState: @unchecked Sendable {} - -extension WorkspaceDocument.SearchState { - /// Creates a search term based on the given query and search mode. +extension SearchState { + /// Evaluates a matched file to determine if it contains any search matches. + /// Requires a file score from the search model. /// - /// - Parameter query: The original user query string. + /// Evaluates the file's contents asynchronously. /// - /// - Returns: A modified search term according to the specified search mode. - func getSearchTerm(_ query: String) -> String { - let newQuery = stripSpecialCharacters(from: (caseSensitive ? query : query.lowercased())) - guard let mode = selectedMode.third else { - return newQuery - } - - switch mode { - case .Containing: - return "*\(newQuery)*" - case .StartingWith: - return "\(newQuery)*" - case .EndingWith: - return "*\(newQuery)" - default: - return newQuery - } - } - - func stripSpecialCharacters(from string: String) -> String { - let regex = try? NSRegularExpression(pattern: "[^a-zA-Z0-9]+", options: .caseInsensitive) - return regex!.stringByReplacingMatches( - in: string, - options: [], - range: NSRange(location: 0, length: string.utf16.count), - withTemplate: "*" + /// - Parameters: + /// - fileURL: The `URL` of the file to evaluate. + /// - fileScore: The file's score from a ``SearchIndexer`` + /// - regexPattern: The pattern to evaluate against the file's contents. + /// - Returns: `nil` if there are no relevant search matches, or a search result if matches are found. + func evaluateSearchResult( + fileURL: URL, + fileScore: Float, + regexPattern: String + ) async -> SearchResultModel? { + var newResult = SearchResultModel( + file: SearchResultFile(url: fileURL), + score: fileScore ) - } - - /// Generates a regular expression pattern based on the specified query and search mode. - /// - /// - Parameter query: The original user query string. - /// - /// - Returns: A string representing the regular expression pattern based on the selected search mode. - /// - /// - Note: This function is creating similar patterns to the - /// ``WorkspaceDocument/SearchState-swift.class/getSearchTerm(_:)`` function, - /// Except its using the word boundary anchor(\b) instead of the asterisk(\*). - /// This is needed to highlight the search results correctly. - func getRegexPattern(_ query: String) -> String { - let newQuery = NSRegularExpression.escapedPattern(for: query.trimmingCharacters(in: .whitespacesAndNewlines)) - - guard let mode = selectedMode.third else { - return newQuery - } - - switch mode { - case .Containing: - return "\(newQuery)" - case .StartingWith: - return "\\b\(newQuery)" - case .EndingWith: - return "\(newQuery)\\b" - case .MatchingWord: - return "\\b\(newQuery)\\b" - default: - return newQuery - } - } - - /// Searches the entire workspace for the given string, using the - /// ``WorkspaceDocument/SearchState-swift.class/selectedMode`` modifiers - /// to modify the search if needed. This is done by filtering out files with SearchKit and then searching - /// within each file for the given string. - /// - /// This method will update - /// ``WorkspaceDocument/SearchState-swift.class/searchResult``, - /// ``WorkspaceDocument/SearchState-swift.class/searchResultsFileCount`` - /// and ``WorkspaceDocument/SearchState-swift.class/searchResultCount`` with any matched - /// search results. See ``SearchResultModel`` and ``SearchResultMatchModel`` - /// for more information on search results and matches. - /// - /// - Parameter query: The search query to search for. - func search(_ query: String) async { - clearResults() - - await MainActor.run { - self.searchQuery = query - self.findNavigatorStatus = .searching - } - - let searchQuery = getSearchTerm(query) - let regexPattern = getRegexPattern(query) - - guard let indexer = indexer else { - await setStatus(.failed(errorMessage: "No index found. Try rebuilding the index.")) - return - } - - let asyncController = SearchIndexer.AsyncManager(index: indexer) - let evaluateResultGroup = DispatchGroup() - let evaluateSearchQueue = DispatchQueue(label: "app.codeedit.CodeEdit.EvaluateSearch") - - let searchStream = await asyncController.search(query: searchQuery, 20) - for try await result in searchStream { - for file in result.results { - let fileURL = file.url - let fileScore = file.score - let capturedRegexPattern = regexPattern - evaluateSearchQueue.async(group: evaluateResultGroup) { - evaluateResultGroup.enter() - Task { [weak self] in - guard let self else { - evaluateResultGroup.leave() - return - } - - let result = await self.evaluateSearchResult( - fileURL: fileURL, - fileScore: fileScore, - regexPattern: capturedRegexPattern - ) - - if let result = result { - await self.appendNewResultsToTempResults(newResult: result) - } - evaluateResultGroup.leave() - } - } - } - } - - evaluateResultGroup.notify(queue: evaluateSearchQueue) { - Task { @MainActor [weak self] in - self?.setSearchResults() - } - } - } - - /// Appends a new search result to the temporary search results array on the main thread. - /// - /// - Parameters: - /// - newResult: The `SearchResultModel` to be appended to the temporary search results. - @MainActor - func appendNewResultsToTempResults(newResult: SearchResultModel) { - self.tempSearchResults.append(newResult) - } + await evaluateFile(query: regexPattern, searchResult: &newResult) - /// Sets the search results by updating various properties on the main thread. - /// This function updates `findNavigatorStatus`, `searchResult`, `searchResultCount`, and `searchResultsFileCount` - /// and sets the `tempSearchResults` to an empty array. - /// - Important: Call this function when you are ready to - /// display or use the final search results. - @MainActor - func setSearchResults() { - self.searchResult = self.tempSearchResults.sorted { $0.score > $1.score } - self.searchResultsCount = self.tempSearchResults.map { $0.lineMatches.count }.reduce(0, +) - self.searchResultsFileCount = self.tempSearchResults.count - self.findNavigatorStatus = .found - self.tempSearchResults = [] + return newResult.lineMatches.isEmpty ? nil : newResult } /// Evaluates a search query within the content of a file and updates @@ -232,7 +99,7 @@ extension WorkspaceDocument.SearchState { /// - Parameters: /// - matchRange: The range of the matched substring within the entire file content. /// - fileContent: The content of the file where the match was found. - /// - file: The `CEWorkspaceFile` object representing the file containing the match. + /// - file: The `SearchResultFile` representing the file containing the match. /// - matchWordLength: The length of the matched substring. /// /// - Returns: A `SearchResultMatchModel` instance representing the matching occurrence. @@ -246,7 +113,7 @@ extension WorkspaceDocument.SearchState { private func createMatchModel( from matchRange: Range, fileContent: String, - file: CEWorkspaceFile, + file: SearchResultFile, matchWordLength: Int ) -> SearchResultMatchModel { let preLine = extractPreLine(from: matchRange, fileContent: fileContent) @@ -343,39 +210,4 @@ extension WorkspaceDocument.SearchState { let firstNewLineIndexInPostLine = postLineWithNewLines.firstIndex(of: "\n") ?? postLineWithNewLines.endIndex return String(postLineWithNewLines[.. SearchResultModel? { - var newResult = SearchResultModel( - file: CEWorkspaceFile(url: fileURL), - score: fileScore - ) - - await evaluateFile(query: regexPattern, searchResult: &newResult) - - return newResult.lineMatches.isEmpty ? nil : newResult - } } diff --git a/CodeEditModules/Sources/CESearch/SearchState/SearchState+QueryProcessing.swift b/CodeEditModules/Sources/CESearch/SearchState/SearchState+QueryProcessing.swift new file mode 100644 index 0000000000..991eed2f43 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/SearchState/SearchState+QueryProcessing.swift @@ -0,0 +1,74 @@ +// +// SearchState+QueryProcessing.swift +// CodeEdit +// +// Created by Tommy Ludwig on 02.01.24. +// + +import Foundation + +extension SearchState { + /// Creates a search term based on the given query and search mode. + /// + /// - Parameter query: The original user query string. + /// + /// - Returns: A modified search term according to the specified search mode. + func getSearchTerm(_ query: String) -> String { + let newQuery = stripSpecialCharacters(from: (caseSensitive ? query : query.lowercased())) + guard let mode = selectedMode.third else { + return newQuery + } + + switch mode { + case .Containing: + return "*\(newQuery)*" + case .StartingWith: + return "\(newQuery)*" + case .EndingWith: + return "*\(newQuery)" + default: + return newQuery + } + } + + func stripSpecialCharacters(from string: String) -> String { + let regex = try? NSRegularExpression(pattern: "[^a-zA-Z0-9]+", options: .caseInsensitive) + return regex!.stringByReplacingMatches( + in: string, + options: [], + range: NSRange(location: 0, length: string.utf16.count), + withTemplate: "*" + ) + } + + /// Generates a regular expression pattern based on the specified query and search mode. + /// + /// - Parameter query: The original user query string. + /// + /// - Returns: A string representing the regular expression pattern based on the selected search mode. + /// + /// - Note: This function is creating similar patterns to the + /// ``Workspace/SearchState-swift.class/getSearchTerm(_:)`` function, + /// Except its using the word boundary anchor(\b) instead of the asterisk(\*). + /// This is needed to highlight the search results correctly. + func getRegexPattern(_ query: String) -> String { + let newQuery = NSRegularExpression.escapedPattern(for: query.trimmingCharacters(in: .whitespacesAndNewlines)) + + guard let mode = selectedMode.third else { + return newQuery + } + + switch mode { + case .Containing: + return "\(newQuery)" + case .StartingWith: + return "\\b\(newQuery)" + case .EndingWith: + return "\(newQuery)\\b" + case .MatchingWord: + return "\\b\(newQuery)\\b" + default: + return newQuery + } + } +} diff --git a/CodeEditModules/Sources/CESearch/SearchState/SearchState.swift b/CodeEditModules/Sources/CESearch/SearchState/SearchState.swift new file mode 100644 index 0000000000..aaae4fe4d7 --- /dev/null +++ b/CodeEditModules/Sources/CESearch/SearchState/SearchState.swift @@ -0,0 +1,130 @@ +// +// SearchState.swift +// CodeEdit +// +// Created by Tom Ludwig on 16.01.24. +// + +import Foundation +import CodeEditCore +import Combine + +/// Manages the search/find state for a workspace, including indexing, search results, +/// and find-and-replace operations. Extracted from Workspace to be independently +/// injectable and testable. +public final class SearchState: ObservableObject { + public enum IndexStatus: Equatable, Sendable { + case none + case indexing(progress: Double) + case done + } + + public enum FindNavigatorStatus: Equatable, Sendable { + case none + case searching + case replacing + case found + case replaced(updatedFiles: Int) + case failed(errorMessage: String) + } + + @Published public var searchResult: [SearchResultModel] = [] + @Published public var searchResultsFileCount: Int = 0 + @Published public var searchResultsCount: Int = 0 + /// Stores the user's input, shown when no files are found, and persists across navigation items. + @Published public var searchQuery: String = "" + @Published public var replaceText: String = "" + + /// The find/replace primitive shared with the Editor feature, kept in sync with + /// `searchQuery`/`replaceText` below. See `CodeEditModules/Sources/CodeEditCore`. + public let query = FindReplaceQuery() + + private var queryBridgeCancellables: Set = [] + + @Published public var indexStatus: IndexStatus = .none + + @Published public var findNavigatorStatus: FindNavigatorStatus = .none + + @Published public var shouldFocusSearchField: Bool = false + + public let workspaceURL: URL + + let eventBus: EventBus + + var tempSearchResults = [SearchResultModel]() + public var caseSensitive: Bool = false + public var indexer: SearchIndexer? + public var selectedMode: [SearchModeModel] = [ + .Find, + .Text, + .Containing + ] + + public init(workspaceURL: URL, eventBus: EventBus) { + self.workspaceURL = workspaceURL + self.eventBus = eventBus + self.indexer = SearchIndexer.Memory.create() + addProjectToIndex() + bridgeFindReplaceQuery() + } + + /// Keeps `searchQuery`/`replaceText` and `query` in sync in both directions, so Editor can + /// depend on `query` (a `CodeEditCore` type) without importing this feature. + private func bridgeFindReplaceQuery() { + query.$searchQuery + .receive(on: RunLoop.main) + .sink { [weak self] newQuery in + if self?.searchQuery != newQuery { + self?.searchQuery = newQuery + } + } + .store(in: &queryBridgeCancellables) + $searchQuery + .receive(on: RunLoop.main) + .sink { [weak self] newQuery in + if self?.query.searchQuery != newQuery { + self?.query.searchQuery = newQuery + } + } + .store(in: &queryBridgeCancellables) + + query.$replaceText + .receive(on: RunLoop.main) + .sink { [weak self] newText in + if self?.replaceText != newText { + self?.replaceText = newText + } + } + .store(in: &queryBridgeCancellables) + $replaceText + .receive(on: RunLoop.main) + .sink { [weak self] newText in + if self?.query.replaceText != newText { + self?.query.replaceText = newText + } + } + .store(in: &queryBridgeCancellables) + } + + /// Represents the compare options to be used for find and replace. + /// + /// The `replaceOptions` property is a lazy, computed property that dynamically calculates + /// the compare options based on the values of `selectedMode` and `ignoreCase`. It is used + /// for controlling string replacement behavior for the find and replace functions. + /// + /// - Note: This property is implemented as a lazy property in the main class body because + /// extensions cannot contain stored properties directly. + lazy var replaceOptions: NSString.CompareOptions = { + var options: NSString.CompareOptions = [] + + if selectedMode.second == .RegularExpression { + options.insert(.regularExpression) + } + + if !caseSensitive { + options.insert(.caseInsensitive) + } + + return options + }() +} diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketAccount+Token.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketAccount+Token.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketAccount+Token.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketAccount+Token.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketAccount.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketAccount.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketAccount.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketAccount.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketOAuthConfiguration.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketOAuthConfiguration.swift similarity index 99% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketOAuthConfiguration.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketOAuthConfiguration.swift index 57620ee824..542467d422 100644 --- a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketOAuthConfiguration.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketOAuthConfiguration.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditSettings struct BitBucketOAuthConfiguration: GitRouterConfiguration { let provider = SourceControlAccount.Provider.bitbucketCloud diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketTokenConfiguration.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketTokenConfiguration.swift similarity index 98% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketTokenConfiguration.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketTokenConfiguration.swift index 46173eac19..84bb764ee5 100644 --- a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/BitBucketTokenConfiguration.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/BitBucketTokenConfiguration.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditSettings struct BitBucketTokenConfiguration: GitRouterConfiguration { let provider = SourceControlAccount.Provider.bitbucketCloud diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Model/BitBucketRepositories.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Model/BitBucketRepositories.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/Model/BitBucketRepositories.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Model/BitBucketRepositories.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Model/BitBucketUser.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Model/BitBucketUser.swift similarity index 84% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/Model/BitBucketUser.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Model/BitBucketUser.swift index 4cb323504f..81630558a9 100644 --- a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Model/BitBucketUser.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Model/BitBucketUser.swift @@ -37,7 +37,12 @@ class BitBucketEmail: Codable { extension BitBucketAccount { - func me( + /// Fetches the profile of the currently authenticated Bitbucket user. + /// - Parameters: + /// - session: The session used to make the request; defaults to the shared session. + /// - completion: Called with the user on success, or the request error on failure. + /// - Returns: The started network task, or `nil` if the request could not be constructed. + public func me( _ session: GitURLSession = URLSession.shared, completion: @escaping (_ response: Result) -> Void ) -> GitURLSessionDataTaskProtocol? { diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketOAuthRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketOAuthRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketOAuthRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketOAuthRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketRepositoryRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketRepositoryRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketRepositoryRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketRepositoryRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketTokenRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketTokenRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketTokenRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketTokenRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketUserRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketUserRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Bitbucket/Routers/BitBucketUserRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Bitbucket/Routers/BitBucketUserRouter.swift diff --git a/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubAccount.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubAccount.swift new file mode 100644 index 0000000000..fa7adf5410 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubAccount.swift @@ -0,0 +1,20 @@ +// +// GitHubAccount.swift +// CodeEditModules/GitAccounts +// +// Created by Nanashi Li on 2022/03/31. +// + +import Foundation + +// TODO: DOCS (Nanashi Li) + +/// Entry point for GitHub API requests, bound to the configuration of a signed-in (or anonymous) account. +public struct GitHubAccount { + let configuration: GitHubTokenConfiguration + + /// Creates an account using the given token configuration; defaults to an unauthenticated `github.com` setup. + public init(_ config: GitHubTokenConfiguration = GitHubTokenConfiguration()) { + configuration = config + } +} diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubConfiguration.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubConfiguration.swift similarity index 93% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubConfiguration.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubConfiguration.swift index 4194b49baf..0caec464d5 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubConfiguration.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubConfiguration.swift @@ -6,16 +6,17 @@ // import Foundation +import CodeEditSettings #if canImport(FoundationNetworking) import FoundationNetworking #endif -struct GitHubTokenConfiguration: GitRouterConfiguration { +public struct GitHubTokenConfiguration: GitRouterConfiguration { let provider = SourceControlAccount.Provider.github - var apiEndpoint: String? - var accessToken: String? - let errorDomain: String? = "com.codeedit.models.accounts.github" - let authorizationHeader: String? = "Basic" + public var apiEndpoint: String? + public var accessToken: String? + public let errorDomain: String? = "com.codeedit.models.accounts.github" + public let authorizationHeader: String? = "Basic" /// Custom `Accept` header for API previews. /// @@ -23,12 +24,12 @@ struct GitHubTokenConfiguration: GitRouterConfiguration { /// see: https://developer.github.com/changes/2016-05-12-reactions-api-preview/ private var previewCustomHeaders: [GitHTTPHeader]? - var customHeaders: [GitHTTPHeader]? { + public var customHeaders: [GitHTTPHeader]? { /// More (non-preview) headers can be appended if needed in the future return previewCustomHeaders } - init(_ token: String? = nil, url: String? = nil, previewHeaders: [GitHubPreviewHeader] = []) { + public init(_ token: String? = nil, url: String? = nil, previewHeaders: [GitHubPreviewHeader] = []) { apiEndpoint = url ?? provider.apiURL?.absoluteString accessToken = token?.data(using: .utf8)!.base64EncodedString() previewCustomHeaders = previewHeaders.map { $0.header } diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubOpenness.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubOpenness.swift similarity index 78% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubOpenness.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubOpenness.swift index 0faea8ea31..03224729b8 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubOpenness.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubOpenness.swift @@ -7,7 +7,7 @@ import Foundation -enum GitHubOpenness: String, Codable { +public enum GitHubOpenness: String, Codable { case open case closed case all diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubPreviewHeader.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubPreviewHeader.swift similarity index 94% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubPreviewHeader.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubPreviewHeader.swift index 0a8c415cf6..b6cac56d23 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitHub/GitHubPreviewHeader.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/GitHubPreviewHeader.swift @@ -12,7 +12,7 @@ import Foundation /// Some APIs provide additional data for new (preview) APIs if a custom header is added to the request. /// /// - Note: Preview APIs are subject to change. -enum GitHubPreviewHeader { +public enum GitHubPreviewHeader { /// The `Reactions` preview header provides reactions in `Comment`s. case reactions diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubAccount+deleteReference.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubAccount+deleteReference.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubAccount+deleteReference.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubAccount+deleteReference.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubComment.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubComment.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubComment.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubComment.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubFiles.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubFiles.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubFiles.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubFiles.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubGist.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubGist.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubGist.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubGist.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubIssue.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubIssue.swift similarity index 99% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubIssue.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubIssue.swift index c83a0cef02..cbb349ac6e 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubIssue.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubIssue.swift @@ -10,7 +10,7 @@ import Foundation import FoundationNetworking #endif -class GitHubIssue: Codable { +public class GitHubIssue: Codable { private(set) var id: Int = -1 var url: URL? var repositoryURL: URL? @@ -18,7 +18,7 @@ class GitHubIssue: Codable { var labelsURL: URL? var commentsURL: URL? var eventsURL: URL? - var htmlURL: URL? + public var htmlURL: URL? var number: Int var state: GitHubOpenness? var title: String? @@ -175,7 +175,7 @@ extension GitHubAccount { - parameter completion: Callback for the issue that is created. */ @discardableResult - func postIssue( + public func postIssue( _ session: GitURLSession = URLSession.shared, owner: String, repository: String, diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubPullRequest.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubPullRequest.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubPullRequest.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubPullRequest.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubRepositories.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubRepositories.swift similarity index 99% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubRepositories.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubRepositories.swift index e771bab31f..ce5c459c5b 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubRepositories.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubRepositories.swift @@ -1,5 +1,5 @@ // -// Repositories.swift +// GitHubRepositories.swift // CodeEditModules/GitAccounts // // Created by Nanashi Li on 2022/03/31. diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubReview.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubReview.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubReview.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubReview.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubUser.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubUser.swift similarity index 97% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubUser.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubUser.swift index 563eb5c96c..e5c6c13034 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitHub/Model/GitHubUser.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Model/GitHubUser.swift @@ -10,7 +10,7 @@ import Foundation import FoundationNetworking #endif -class GitHubUser: Codable { +public class GitHubUser: Codable { private(set) var id: Int = -1 var login: String? var avatarURL: String? @@ -24,7 +24,7 @@ class GitHubUser: Codable { var numberOfPrivateRepos: Int? var nodeID: String? var url: String? - var htmlURL: String? +public var htmlURL: String? var gistsURL: String? var starredURL: String? var subscriptionsURL: String? @@ -102,7 +102,7 @@ extension GitHubAccount { - parameter completion: Callback for the outcome of the fetch. */ @discardableResult - func me( + public func me( _ session: GitURLSession = URLSession.shared, completion: @escaping (_ response: Result) -> Void ) -> GitURLSessionDataTaskProtocol? { diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/PublicKey.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/PublicKey.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/PublicKey.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/PublicKey.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubGistRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubGistRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubGistRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubGistRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubIssueRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubIssueRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubIssueRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubIssueRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubPullRequestRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubPullRequestRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubPullRequestRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubPullRequestRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubRepositoryRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubRepositoryRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubRepositoryRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubRepositoryRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubReviewsRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubReviewsRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubReviewsRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubReviewsRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubUserRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubUserRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitHub/Routers/GitHubUserRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitHub/Routers/GitHubUserRouter.swift diff --git a/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabAccount.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabAccount.swift new file mode 100644 index 0000000000..8c160afa6a --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabAccount.swift @@ -0,0 +1,20 @@ +// +// GitLabAccount.swift +// CodeEditModules/GitAccounts +// +// Created by Nanashi Li on 2022/03/31. +// + +import Foundation + +// TODO: DOCS (Nanashi Li) + +/// Entry point for GitLab API requests, bound to the configuration of a signed-in (or anonymous) account. +public struct GitLabAccount { + let configuration: GitRouterConfiguration + + /// Creates an account using the given router configuration; defaults to an unauthenticated `gitlab.com` setup. + public init(_ config: GitRouterConfiguration = GitLabTokenConfiguration()) { + configuration = config + } +} diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/GitLabConfiguration.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabConfiguration.swift similarity index 66% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/GitLabConfiguration.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabConfiguration.swift index 756f0a6dac..2d3d293dcc 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitLab/GitLabConfiguration.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabConfiguration.swift @@ -6,14 +6,15 @@ // import Foundation +import CodeEditSettings -struct GitLabTokenConfiguration: GitRouterConfiguration { +public struct GitLabTokenConfiguration: GitRouterConfiguration { let provider = SourceControlAccount.Provider.gitlab - var apiEndpoint: String? - var accessToken: String? - let errorDomain: String? = "com.codeedit.models.accounts.gitlab" + public var apiEndpoint: String? + public var accessToken: String? + public let errorDomain: String? = "com.codeedit.models.accounts.gitlab" - init(_ token: String? = nil, url: String? = nil) { + public init(_ token: String? = nil, url: String? = nil) { apiEndpoint = url ?? provider.apiURL?.absoluteString accessToken = token } @@ -25,7 +26,7 @@ struct GitLabPrivateTokenConfiguration: GitRouterConfiguration { var accessToken: String? let errorDomain: String? = "com.codeedit.models.accounts.gitlab" - init(_ token: String? = nil, url: String? = nil) { + public init(_ token: String? = nil, url: String? = nil) { apiEndpoint = url ?? provider.apiURL?.absoluteString accessToken = token } diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/GitLabOAuthConfiguration.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabOAuthConfiguration.swift similarity index 99% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/GitLabOAuthConfiguration.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabOAuthConfiguration.swift index 8570631013..e9c858d9fe 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitLab/GitLabOAuthConfiguration.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/GitLabOAuthConfiguration.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditSettings struct GitLabOAuthConfiguration: GitRouterConfiguration { let provider = SourceControlAccount.Provider.gitlab diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabAccountModel.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabAccountModel.swift similarity index 99% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabAccountModel.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabAccountModel.swift index aeae53cc21..ad54d3f50b 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabAccountModel.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabAccountModel.swift @@ -1,5 +1,5 @@ // -// GitLabAccount.swift +// GitLabAccountModel.swift // CodeEditModules/GitAccounts // // Created by Wesley de Groot on 02/04/2022. diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabAvatarURL.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabAvatarURL.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabAvatarURL.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabAvatarURL.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabCommit.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabCommit.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabCommit.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabCommit.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabEvent.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabEvent.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabEvent.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabEvent.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabEventData.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabEventData.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabEventData.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabEventData.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabEventNote.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabEventNote.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabEventNote.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabEventNote.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabGroupAccess.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabGroupAccess.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabGroupAccess.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabGroupAccess.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabNamespace.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabNamespace.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabNamespace.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabNamespace.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabPermissions.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabPermissions.swift similarity index 94% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabPermissions.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabPermissions.swift index 9d16864b97..25f9147ceb 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabPermissions.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabPermissions.swift @@ -1,5 +1,5 @@ // -// Permissions.swift +// GitLabPermissions.swift // CodeEditModules/GitAccounts // // Created by Nanashi Li on 2022/03/31. diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabProject.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabProject.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabProject.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabProject.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabProjectAccess.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabProjectAccess.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabProjectAccess.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabProjectAccess.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabProjectHook.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabProjectHook.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabProjectHook.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabProjectHook.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabUser.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabUser.swift similarity index 97% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabUser.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabUser.swift index c58d0299eb..a3d41424c9 100644 --- a/CodeEdit/Features/SourceControl/Accounts/GitLab/Model/GitLabUser.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Model/GitLabUser.swift @@ -7,7 +7,7 @@ import Foundation -class GitLabUser: Codable { +public class GitLabUser: Codable { var id: Int var username: String? var state: String? @@ -62,7 +62,7 @@ extension GitLabAccount { - parameter completion: Callback for the outcome of the fetch. */ @discardableResult - func me( + public func me( _ session: GitURLSession = URLSession.shared, completion: @escaping (_ response: Result) -> Void ) -> GitURLSessionDataTaskProtocol? { diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabCommitRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabCommitRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabCommitRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabCommitRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabOAuthRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabOAuthRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabOAuthRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabOAuthRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabProjectRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabProjectRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabProjectRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabProjectRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabUserRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabUserRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/GitLab/Routers/GitLabUserRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/GitLab/Routers/GitLabUserRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Networking/GitJSONPostRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitJSONPostRouter.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Networking/GitJSONPostRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitJSONPostRouter.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Networking/GitRouter.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitRouter.swift similarity index 90% rename from CodeEdit/Features/SourceControl/Accounts/Networking/GitRouter.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitRouter.swift index 2dedbd43f0..51b54f7dc1 100644 --- a/CodeEdit/Features/SourceControl/Accounts/Networking/GitRouter.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitRouter.swift @@ -18,34 +18,46 @@ enum GitHTTPEncoding: Int { case url, form, json } -struct GitHTTPHeader { +/// A single HTTP header (field name and value) to attach to git provider API requests. +public struct GitHTTPHeader { var headerField: String var value: String } -protocol GitRouterConfiguration { +/// Describes how to reach and authenticate against a git provider's REST API (GitHub, GitLab, Bitbucket). +public protocol GitRouterConfiguration { + /// The base URL of the provider's API, e.g. `https://api.github.com`. var apiEndpoint: String? { get } + /// The token used to authenticate requests, if the account is signed in. var accessToken: String? { get } + /// The query-parameter name the provider expects the access token under. var accessTokenFieldName: String? { get } + /// The authorization scheme (e.g. `Bearer`) used to send the token in an `Authorization` header instead. var authorizationHeader: String? { get } + /// The domain used when constructing errors for failed requests. var errorDomain: String? { get } + /// Additional headers to attach to every request made with this configuration. var customHeaders: [GitHTTPHeader]? { get } } extension GitRouterConfiguration { - var accessTokenFieldName: String? { + /// By default the access token is sent in the `access_token` query field. + public var accessTokenFieldName: String? { "access_token" } - var authorizationHeader: String? { + /// By default the token is sent as a query parameter instead of an `Authorization` header. + public var authorizationHeader: String? { nil } - var errorDomain: String? { + /// The default domain used for errors produced by failed account requests. + public var errorDomain: String? { "com.codeedit.models.accounts.networking" } - var customHeaders: [GitHTTPHeader]? { + /// By default no additional headers are attached to requests. + public var customHeaders: [GitHTTPHeader]? { nil } } diff --git a/CodeEdit/Features/SourceControl/Accounts/Networking/GitURLSession.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitURLSession.swift similarity index 68% rename from CodeEdit/Features/SourceControl/Accounts/Networking/GitURLSession.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitURLSession.swift index 6a8d62bb36..5da2dac97d 100644 --- a/CodeEdit/Features/SourceControl/Accounts/Networking/GitURLSession.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/Networking/GitURLSession.swift @@ -1,5 +1,5 @@ // -// Session.swift +// GitURLSession.swift // CodeEditModules/GitAccounts // // Created by Nanashi Li on 2022/03/31. @@ -13,13 +13,16 @@ import FoundationNetworking #endif // TODO: DOCS (Nanashi Li) -protocol GitURLSession { +/// Abstraction over `URLSession` for git account API requests, allowing the session to be mocked in tests. +public protocol GitURLSession { + /// Creates a data task that fetches the given request and calls the handler with the response. func dataTask( with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Swift.Void ) -> GitURLSessionDataTaskProtocol + /// Creates a task that uploads the given body data for the request and calls the handler with the response. func uploadTask( with request: URLRequest, fromData bodyData: Data?, @@ -27,12 +30,14 @@ protocol GitURLSession { ) -> GitURLSessionDataTaskProtocol #if !canImport(FoundationNetworking) + /// Fetches the given request asynchronously, returning the response body and metadata. @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func data( for request: URLRequest, delegate: URLSessionTaskDelegate? ) async throws -> (Data, URLResponse) + /// Uploads the given body data for the request asynchronously, returning the response body and metadata. @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) func upload( for request: URLRequest, @@ -42,7 +47,9 @@ protocol GitURLSession { #endif } -protocol GitURLSessionDataTaskProtocol { +/// Abstraction over `URLSessionDataTask` so tasks returned by a ``GitURLSession`` can be mocked in tests. +public protocol GitURLSessionDataTaskProtocol { + /// Starts (or resumes) the network task. func resume() } @@ -50,14 +57,14 @@ extension URLSessionDataTask: GitURLSessionDataTaskProtocol {} extension URLSession: GitURLSession { - func dataTask( + public func dataTask( with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Swift.Void ) -> GitURLSessionDataTaskProtocol { (dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask) } - func uploadTask( + public func uploadTask( with request: URLRequest, fromData bodyData: Data?, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void diff --git a/CodeEdit/Features/SourceControl/Accounts/Parameters.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Parameters.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Parameters.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Parameters.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Utils/GitTime.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Utils/GitTime.swift similarity index 81% rename from CodeEdit/Features/SourceControl/Accounts/Utils/GitTime.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Utils/GitTime.swift index 8f41b24c91..2b593fb970 100644 --- a/CodeEdit/Features/SourceControl/Accounts/Utils/GitTime.swift +++ b/CodeEditModules/Sources/CESourceControl/Accounts/Utils/GitTime.swift @@ -10,13 +10,15 @@ import Foundation // TODO: DOCS (Nanashi Li) enum GitTime { + // nonisolated(unsafe): configured once here and never mutated afterwards; + // DateFormatter is thread-safe for reading once configuration is complete. /** A date formatter for RFC 3339 style timestamps. Uses POSIX locale and GMT timezone so that date values are parsed as absolutes. - (https://tools.ietf.org/html/rfc3339) - (https://developer.apple.com/library/mac/qa/qa1480/_index.html) */ - static var rfc3339DateFormatter: DateFormatter = { + nonisolated(unsafe) static let rfc3339DateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'" formatter.locale = Locale(identifier: "en_US_POSIX") diff --git a/CodeEdit/Features/SourceControl/Accounts/Utils/String+PercentEncoding.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Utils/String+PercentEncoding.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Utils/String+PercentEncoding.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Utils/String+PercentEncoding.swift diff --git a/CodeEdit/Features/SourceControl/Accounts/Utils/String+QueryParameters.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Utils/String+QueryParameters.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Utils/String+QueryParameters.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Utils/String+QueryParameters.swift diff --git a/CodeEdit/Utils/Extensions/URL/URL+URLParameters.swift b/CodeEditModules/Sources/CESourceControl/Accounts/Utils/URL+URLParameters.swift similarity index 100% rename from CodeEdit/Utils/Extensions/URL/URL+URLParameters.swift rename to CodeEditModules/Sources/CESourceControl/Accounts/Utils/URL+URLParameters.swift diff --git a/CodeEditModules/Sources/CESourceControl/Branches/GitBranchesGroup.swift b/CodeEditModules/Sources/CESourceControl/Branches/GitBranchesGroup.swift new file mode 100644 index 0000000000..def147968b --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Branches/GitBranchesGroup.swift @@ -0,0 +1,25 @@ +// +// GitBranchesGroup.swift +// CodeEdit +// +// Created by Federico Zivolo on 22/01/24. +// + +import Foundation +import CodeEditCore + +public struct GitBranchesGroup: Hashable, Sendable { + public let name: String + public var branches: [GitBranch] + public var shouldNest: Bool { + branches.first?.name.hasPrefix(name + "/") ?? false + } + + public init( + name: String, + branches: [GitBranch] + ) { + self.name = name + self.branches = branches + } +} diff --git a/CodeEdit/Utils/Formatters/RegexFormatter.swift b/CodeEditModules/Sources/CESourceControl/Branches/RegexFormatter.swift similarity index 100% rename from CodeEdit/Utils/Formatters/RegexFormatter.swift rename to CodeEditModules/Sources/CESourceControl/Branches/RegexFormatter.swift diff --git a/CodeEdit/Features/SourceControl/Views/RemoteBranchPicker.swift b/CodeEditModules/Sources/CESourceControl/Branches/RemoteBranchPicker.swift similarity index 94% rename from CodeEdit/Features/SourceControl/Views/RemoteBranchPicker.swift rename to CodeEditModules/Sources/CESourceControl/Branches/RemoteBranchPicker.swift index 165b5d3220..5462d376b1 100644 --- a/CodeEdit/Features/SourceControl/Views/RemoteBranchPicker.swift +++ b/CodeEditModules/Sources/CESourceControl/Branches/RemoteBranchPicker.swift @@ -6,9 +6,12 @@ // import SwiftUI +import CodeEditSymbols +import CodeEditCore struct RemoteBranchPicker: View { @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @Binding var branch: GitBranch? @Binding var remote: GitRemote? @@ -69,7 +72,7 @@ struct RemoteBranchPicker: View { } .onChange(of: remote) { _, newValue in if newValue == nil { - sourceControlManager.addExistingRemoteSheetIsPresented = true + sourceControlViewModel.addExistingRemoteSheetIsPresented = true } else { updateBranch() } diff --git a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift b/CodeEditModules/Sources/CESourceControl/Branches/ToolbarBranchPicker.swift similarity index 91% rename from CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift rename to CodeEditModules/Sources/CESourceControl/Branches/ToolbarBranchPicker.swift index 7993da1802..910760edbb 100644 --- a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift +++ b/CodeEditModules/Sources/CESourceControl/Branches/ToolbarBranchPicker.swift @@ -6,12 +6,15 @@ // import SwiftUI +import CodeEditSettings +import CodeEditCore import CodeEditSymbols +import CodeEditUI import Combine /// A view that pops up a branch picker. -struct ToolbarBranchPicker: View { - private weak var workspaceFileManager: CEWorkspaceFileManager? +public struct ToolbarBranchPicker: View { + private let fallbackTitle: String private weak var sourceControlManager: SourceControlManager? @Environment(\.controlActiveState) @@ -21,16 +24,20 @@ struct ToolbarBranchPicker: View { @State private var displayPopover: Bool = false @State private var currentBranch: GitBranch? - /// Initializes the ``ToolbarBranchPicker`` with an instance of a `WorkspaceClient` - /// - Parameter workspace: An instance of the current `WorkspaceClient` - init( - workspaceFileManager: CEWorkspaceFileManager? + @SettingsValue(SourceControlSettings.self, \.general.sourceControlIsEnabled) + private var sourceControlIsEnabled + + /// Initializes the picker with the workspace's display name (shown when no + /// branch is available) and its source-control manager. + public init( + fallbackTitle: String, + sourceControlManager: SourceControlManager? ) { - self.workspaceFileManager = workspaceFileManager - self.sourceControlManager = workspaceFileManager?.sourceControlManager + self.fallbackTitle = fallbackTitle + self.sourceControlManager = sourceControlManager } - var body: some View { + public var body: some View { HStack(alignment: .center, spacing: 7) { Group { if currentBranch != nil { @@ -51,7 +58,7 @@ struct ToolbarBranchPicker: View { .help(title) if let currentBranch { Menu(content: { - if let sourceControlManager = workspaceFileManager?.sourceControlManager { + if let sourceControlManager { PopoverView(sourceControlManager: sourceControlManager) } }, label: { @@ -84,7 +91,7 @@ struct ToolbarBranchPicker: View { self.currentBranch = branch } .task { - if Settings.shared.preferences.sourceControl.general.sourceControlIsEnabled { + if sourceControlIsEnabled { await self.sourceControlManager?.refreshCurrentBranch() await self.sourceControlManager?.refreshBranches() } @@ -99,7 +106,7 @@ struct ToolbarBranchPicker: View { } private var title: String { - workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty" + fallbackTitle } // MARK: Popover View diff --git a/CodeEdit/Utils/Formatters/TrimWhitespaceFormatter.swift b/CodeEditModules/Sources/CESourceControl/Branches/TrimWhitespaceFormatter.swift similarity index 100% rename from CodeEdit/Utils/Formatters/TrimWhitespaceFormatter.swift rename to CodeEditModules/Sources/CESourceControl/Branches/TrimWhitespaceFormatter.swift diff --git a/CodeEditModules/Sources/CESourceControl/CESourceControl.swift b/CodeEditModules/Sources/CESourceControl/CESourceControl.swift new file mode 100644 index 0000000000..f08d5fd4a4 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/CESourceControl.swift @@ -0,0 +1,11 @@ +// +// CESourceControl.swift +// CESourceControl +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +/// The CESourceControl feature package: git state management (`SourceControlManager`), +/// the git CLI client, source-control operation views, and the GitHub/GitLab/Bitbucket +/// account clients. +enum CESourceControl {} diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Branches.swift similarity index 88% rename from CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Branches.swift index 328ff81422..90014b40ee 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Branches.swift @@ -6,12 +6,20 @@ // import Foundation +import CodeEditCore + +private extension CharacterSet { + /// Whitespace excluding newlines. Previously leaked in from TextFormation via the + /// app target's cross-file extension visibility; defined locally so this package + /// doesn't depend on an editor-formatting library for a one-liner. + static let whitespacesWithoutNewlines = CharacterSet.whitespacesAndNewlines.subtracting(.newlines) +} extension GitClient { /// Get branches /// - Parameter remote: If passed, fetches branches for the specified remote /// - Returns: Array of branches - func getBranches(remote: String? = nil) async throws -> [GitBranch] { + public func getBranches(remote: String? = nil) async throws -> [GitBranch] { var command = "branch --format \"%(refname:short)|%(refname)|%(upstream:short) %(upstream:track)\"" if remote != nil { command += " -r" @@ -44,7 +52,7 @@ extension GitClient { } /// Get current branch - func getCurrentBranch() async throws -> GitBranch? { + public func getCurrentBranch() async throws -> GitBranch? { let branchName = try await run("branch --show-current").trimmingCharacters(in: .whitespacesAndNewlines) let output = try await run( "for-each-ref --format=\"%(refname)|%(upstream:short) %(upstream:track)\" refs/heads/\(branchName)" @@ -70,7 +78,7 @@ extension GitClient { } /// Delete branch - func deleteBranch(_ branch: GitBranch) async throws { + public func deleteBranch(_ branch: GitBranch) async throws { if !branch.isLocal { return } @@ -81,13 +89,13 @@ extension GitClient { /// Rename branch /// - Parameter from: Name of the branch to rename /// - Parameter to: New name for branch - func renameBranch(oldName: String, newName: String) async throws { + public func renameBranch(oldName: String, newName: String) async throws { _ = try await run("branch -m \(oldName) \(newName)") } /// Checkout branch /// - Parameter branch: Branch to checkout - func checkoutBranch(_ branch: GitBranch, forceLocal: Bool = false, newName: String? = nil) async throws { + public func checkoutBranch(_ branch: GitBranch, forceLocal: Bool = false, newName: String? = nil) async throws { var command = "checkout " let targetName = newName ?? branch.name diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Clone.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Clone.swift similarity index 90% rename from CodeEdit/Features/SourceControl/Client/GitClient+Clone.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Clone.swift index 9d8a9d93aa..48bb404039 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Clone.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Clone.swift @@ -7,9 +7,14 @@ import Foundation import Combine +import CodeEditCore extension GitClient { - struct CloneProgress { + /// A snapshot of clone progress: the total percentage (0-100) and the phase git is currently in. + /// + /// `Sendable` because these are produced by an `AsyncSequence` and consumed on the main actor. + /// It holds a `Double` and a payload-free internal enum, so the conformance is free. + public struct CloneProgress: Sendable { let progress: Double let state: GitCloneProgressState } @@ -37,7 +42,7 @@ extension GitClient { /// - remoteUrl: URL of remote repository /// - localPath: Local path to clone /// - Returns: Stream of progress - func cloneRepository( + public func cloneRepository( remoteUrl: URL, localPath: URL ) -> AsyncThrowingMapSequence { diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Commit.swift similarity index 83% rename from CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Commit.swift index 9a8dc0c9a3..2973c15954 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Commit.swift @@ -7,12 +7,13 @@ import Foundation import RegexBuilder +import CodeEditCore extension GitClient { /// Commit files /// - Parameters: /// - message: Commit message - func commit(message: String, details: String?) async throws { + public func commit(message: String, details: String?) async throws { let message = message.replacingOccurrences(of: #"""#, with: #"\""#) let command: String @@ -27,24 +28,27 @@ extension GitClient { /// Add file to git /// - Parameter file: File to add - func add(_ files: [URL]) async throws { + public func add(_ files: [URL]) async throws { let output = try await run("add \(files.map { "'\($0.path(percentEncoded: false))'" }.joined(separator: " "))") print(output) } /// Add file to git /// - Parameter file: File to add - func reset(_ files: [URL]) async throws { + public func reset(_ files: [URL]) async throws { _ = try await run("reset \(files.map { "'\($0.path(percentEncoded: false))'" }.joined(separator: " "))") } /// Returns tuple of unsynced commits both ahead and behind - func numberOfUnsyncedCommits() async throws -> (ahead: Int, behind: Int) { + public func numberOfUnsyncedCommits() async throws -> (ahead: Int, behind: Int) { let output = try await run("status -sb --porcelain=v2").trimmingCharacters(in: .whitespacesAndNewlines) return try parseUnsyncedCommitsOutput(from: output) } - func getCommitChangedFiles(commitSHA: String) async throws -> [GitChangedFile] { + /// Lists the files a commit changed, using `git diff-tree` to compare the commit against its parent. + /// - Parameter commitSHA: The hash of the commit to inspect. + /// - Returns: The changed files with their change status, or an empty array if the lookup fails. + public func getCommitChangedFiles(commitSHA: String) async throws -> [GitChangedFile] { do { let output = try await run("diff-tree --no-commit-id --name-status -r \(commitSHA)") let data = output diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+CommitHistory.swift similarity index 98% rename from CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+CommitHistory.swift index 6245fe7739..7bf9a3d645 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+CommitHistory.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditCore extension GitClient { /// Gets the commit history log for the specified branch or file @@ -14,7 +15,7 @@ extension GitClient { /// - maxCount: Maximum amount of entries to get /// - fileLocalPath: Optional path of file to get history for /// - Returns: Array of git commits - func getCommitHistory( + public func getCommitHistory( branchName: String? = nil, maxCount: Int? = nil, fileLocalPath: String? = nil, diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Fetch.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Fetch.swift similarity index 83% rename from CodeEdit/Features/SourceControl/Client/GitClient+Fetch.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Fetch.swift index 05964b7921..5d5792cb27 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Fetch.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Fetch.swift @@ -9,7 +9,7 @@ import Foundation extension GitClient { /// Fetch changes to remote - func fetchFromRemote() async throws { + public func fetchFromRemote() async throws { let command = "fetch" _ = try await self.run(command) diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Initiate.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Initiate.swift similarity index 83% rename from CodeEdit/Features/SourceControl/Client/GitClient+Initiate.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Initiate.swift index 2d87d325b9..c8a9bd9604 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Initiate.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Initiate.swift @@ -9,7 +9,7 @@ import Foundation extension GitClient { /// Initiate Git repository - func initiate() async throws { + public func initiate() async throws { _ = try await run("init") } } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Pull.swift similarity index 77% rename from CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Pull.swift index d5f8c9ca63..9ba88f3ed8 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Pull.swift @@ -9,7 +9,7 @@ import Foundation extension GitClient { /// Pull changes from remote - func pullFromRemote(remote: String? = nil, branch: String? = nil, rebase: Bool = false) async throws { + public func pullFromRemote(remote: String? = nil, branch: String? = nil, rebase: Bool = false) async throws { var command = "pull \(rebase ? "--rebase" : "--no-rebase")" if let remote = remote, let branch = branch { diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Push.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Push.swift similarity index 96% rename from CodeEdit/Features/SourceControl/Client/GitClient+Push.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Push.swift index 4295c075ae..dea73152cf 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Push.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Push.swift @@ -9,7 +9,7 @@ import Foundation extension GitClient { /// Push changes to remote - func pushToRemote( + public func pushToRemote( remote: String? = nil, branch: String? = nil, setUpstream: Bool? = false, diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Remote.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Remote.swift similarity index 91% rename from CodeEdit/Features/SourceControl/Client/GitClient+Remote.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Remote.swift index 04307430b3..90d5d0bbbf 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Remote.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Remote.swift @@ -6,12 +6,13 @@ // import Foundation +import CodeEditCore extension GitClient { /// Gets all remotes /// - Parameter name: Name for remote /// - Parameter location: URL string for remote location - func getRemotes() async throws -> [GitRemote] { + public func getRemotes() async throws -> [GitRemote] { let command = "remote -v" let output = try await run(command) let remotes = parseGitRemotes(from: output) @@ -22,13 +23,13 @@ extension GitClient { /// Add existing remote to local git /// - Parameter name: Name for remote /// - Parameter location: URL string for remote location - func addRemote(name: String, location: String) async throws { + public func addRemote(name: String, location: String) async throws { _ = try await run("remote add \(name) \(location)") } /// Remove remote from local git /// - Parameter name: Name for remote to remove - func removeRemote(name: String) async throws { + public func removeRemote(name: String) async throws { _ = try await run("remote rm \(name)") } @@ -38,7 +39,7 @@ extension GitClient { /// > (Reference: https://git-scm.com/docs/git-ls-remote, https://git-scm.com/docs/git-fetch) /// - Returns: A URL if a remote is configured, nil otherwise /// - Throws: `GitClientError.outputError` if the underlying git command fails unexpectedly - func getRemoteURL() async throws -> URL? { + public func getRemoteURL() async throws -> URL? { do { let remote = try await run("ls-remote --get-url") return URL(string: remote.trimmingCharacters(in: .whitespacesAndNewlines)) diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Stash.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Stash.swift similarity index 87% rename from CodeEdit/Features/SourceControl/Client/GitClient+Stash.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Stash.swift index 69a0a82116..911b2305c4 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Stash.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Stash.swift @@ -6,24 +6,25 @@ // import Foundation +import CodeEditCore extension GitClient { /// Add uncommited changes to stash - func stash(message: String?) async throws { + public func stash(message: String?) async throws { let command = message != nil ? "stash save --message=\"\(message ?? "")\"" : "stash" _ = try await self.run(command) } /// Pops the latest entry from stash onto HEAD - func stashPop() async throws { + public func stashPop() async throws { let command = "stash pop" _ = try await self.run(command) } /// Lists all of the entries in stash - func stashList() async throws -> [GitStashEntry] { + public func stashList() async throws -> [GitStashEntry] { let command = "stash list --date=local" let output = try await run(command) let stashEntries = parseGitStashEntries(output) @@ -32,7 +33,7 @@ extension GitClient { } /// Apply stash - func applyStashEntry(_ index: Int?) async throws { + public func applyStashEntry(_ index: Int?) async throws { if let idx = index { _ = try await run("stash apply stash@{\(idx)}") } else { @@ -41,7 +42,7 @@ extension GitClient { } /// Delete stash - func deleteStashEntry(_ index: Int) async throws { + public func deleteStashEntry(_ index: Int) async throws { _ = try await run("stash drop stash@{\(index)}") } } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Status.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Status.swift similarity index 93% rename from CodeEdit/Features/SourceControl/Client/GitClient+Status.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Status.swift index 5883f7782c..27936e5c2e 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Status.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Status.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditCore /// Methods for parsing git's porcelain v2 format and returning the info in a ``GitClient/Status`` struct. /// @@ -25,7 +26,12 @@ import Foundation /// information can be included in the same call. extension GitClient { - struct Status { + /// The parsed result of `git status`: ordinary changes, unmerged (conflicting) paths, and untracked files. + /// + /// `Sendable` because this crosses ``GitClientProtocol``, which is itself `Sendable`, and every + /// other type returned by that protocol declares the conformance. It holds only + /// `[GitChangedFile]`, which is `Sendable`, so the conformance is free. + public struct Status: Sendable { var changedFiles: [GitChangedFile] var unmergedChanges: [GitChangedFile] var untrackedFiles: [GitChangedFile] @@ -34,7 +40,7 @@ extension GitClient { /// Fetches and parses the git repository's status. /// - Returns: A ``GitClient/Status`` struct with information about the changed files in the repository. /// - Throws: Can throw ``GitClient/GitClientError`` errors if it finds unexpected output. - func getStatus() async throws -> Status { + public func getStatus() async throws -> Status { let output = try await run("status -z --porcelain=2 -u") return try parseStatusString(output) } @@ -42,7 +48,7 @@ extension GitClient { /// Parses a status string from ``getStatus()`` and returns a ``Status`` object if possible. /// - Parameter output: The git output from running `status`. Expects a porcelain v2 string. /// - Returns: A status object if parseable. - func parseStatusString(_ output: borrowing String) throws -> Status { + public func parseStatusString(_ output: borrowing String) throws -> Status { let endsInNull = output.last == Character(UnicodeScalar(0)) let endIndex: String.Index if endsInNull && output.count > 1 { @@ -83,12 +89,12 @@ extension GitClient { } /// Discard changes for file - func discardChanges(for file: URL) async throws { + public func discardChanges(for file: URL) async throws { _ = try await run("restore '\(file.path(percentEncoded: false))'") } /// Discard unstaged changes - func discardAllChanges() async throws { + public func discardAllChanges() async throws { _ = try await run("restore .") } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Validate.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Validate.swift similarity index 92% rename from CodeEdit/Features/SourceControl/Client/GitClient+Validate.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient+Validate.swift index 75d01660c5..847faec855 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Validate.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient+Validate.swift @@ -13,7 +13,7 @@ extension GitClient { /// Runs `git rev-parse --is-inside-work-tree`. /// /// - Returns: True, if git finds a valid repository. - func validate() async -> Bool { + public func validate() async -> Bool { do { let output = try await run("rev-parse --is-inside-work-tree") return output.trimmingCharacters(in: .whitespacesAndNewlines) == "true" diff --git a/CodeEdit/Features/SourceControl/Client/GitClient.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClient.swift similarity index 86% rename from CodeEdit/Features/SourceControl/Client/GitClient.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitClient.swift index eb1643cfbf..44c3fc9298 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClient.swift @@ -7,9 +7,10 @@ import Combine import Foundation +import CodeEditCore import OSLog -class GitClient { +public final class GitClient: GitClientProtocol { enum GitClientError: Error { case outputError(String) case notGitRepository @@ -36,21 +37,21 @@ class GitClient { let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "GitClient") internal let directoryURL: URL - internal let shellClient: ShellClient + internal let shellClient: ShellClientProtocol private let configClient: GitConfigClient - init(directoryURL: URL, shellClient: ShellClient) { + init(directoryURL: URL, shellClient: ShellClientProtocol) { self.directoryURL = directoryURL self.shellClient = shellClient self.configClient = GitConfigClient(projectURL: directoryURL, shellClient: shellClient) } - func getConfig(key: String) async throws -> T? { + public func getConfig(key: String) async throws -> T? { return try await configClient.get(key: key, global: false) } - func setConfig(key: String, value: T) async { + public func setConfig(key: String, value: T) async { await configClient.set(key: key, value: value, global: false) } @@ -61,7 +62,7 @@ class GitClient { return try processCommonErrors(output) } - internal typealias LiveCommandStream = AsyncThrowingMapSequence, String> + public typealias LiveCommandStream = AsyncThrowingMapSequence, String> /// Runs a git command in same way as `run`, but returns a async stream of the output internal func runLive(_ command: String) -> LiveCommandStream { diff --git a/CodeEditModules/Sources/CESourceControl/Client/GitClientProtocol.swift b/CodeEditModules/Sources/CESourceControl/Client/GitClientProtocol.swift new file mode 100644 index 0000000000..ed1e002daa --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Client/GitClientProtocol.swift @@ -0,0 +1,67 @@ +// +// GitClientProtocol.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 07.04.26. +// + +import Foundation +import CodeEditCore + +/// Abstraction over git operations used by ``SourceControlManager``. +/// +/// This protocol decouples the manager from the concrete ``GitClient`` class, +/// enabling mock-based testing without hitting the shell. +/// `Sendable`: implementations must be stateless command executors (the concrete +/// `GitClient` holds only immutable `let` configuration), so they may be used from +/// any concurrency domain. +public protocol GitClientProtocol: Sendable { + // MARK: - Repository + + func validate() async -> Bool + func initiate() async throws + + // MARK: - Branches + + func getBranches(remote: String?) async throws -> [GitBranch] + func getCurrentBranch() async throws -> GitBranch? + func checkoutBranch(_ branch: GitBranch, forceLocal: Bool, newName: String?) async throws + func renameBranch(oldName: String, newName: String) async throws + func deleteBranch(_ branch: GitBranch) async throws + + // MARK: - Stash + + func stash(message: String?) async throws + func stashList() async throws -> [GitStashEntry] + func applyStashEntry(_ index: Int?) async throws + func deleteStashEntry(_ index: Int) async throws + + // MARK: - History + + func getCommitHistory( + branchName: String?, + maxCount: Int?, + fileLocalPath: String?, + showMergeCommits: Bool + ) async throws -> [GitCommit] + + // MARK: - Status & Files + + func getStatus() async throws -> GitClient.Status + func commit(message: String, details: String?) async throws + func add(_ files: [URL]) async throws + func reset(_ files: [URL]) async throws + func numberOfUnsyncedCommits() async throws -> (ahead: Int, behind: Int) + func getCommitChangedFiles(commitSHA: String) async throws -> [GitChangedFile] + func discardChanges(for file: URL) async throws + func discardAllChanges() async throws + + // MARK: - Remotes + + func getRemotes() async throws -> [GitRemote] + func addRemote(name: String, location: String) async throws + func removeRemote(name: String) async throws + func fetchFromRemote() async throws + func pullFromRemote(remote: String?, branch: String?, rebase: Bool) async throws + func pushToRemote(remote: String?, branch: String?, setUpstream: Bool?, force: Bool?, tags: Bool?) async throws +} diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift b/CodeEditModules/Sources/CESourceControl/Client/GitConfigClient.swift similarity index 84% rename from CodeEdit/Features/SourceControl/Client/GitConfigClient.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitConfigClient.swift index 7323715e46..34c5fb77fd 100644 --- a/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitConfigClient.swift @@ -6,19 +6,21 @@ // import Foundation +import CodeEditCore /// A client for managing Git configuration settings. /// Provides methods to read and write Git configuration values at both /// project and global levels. -class GitConfigClient { +/// Stateless config reader (immutable configuration only), hence `Sendable`. +public final class GitConfigClient: Sendable { private let projectURL: URL? - private let shellClient: ShellClient + private let shellClient: ShellClientProtocol /// Initializes a new GitConfigClient. /// - Parameters: /// - projectURL: The project directory URL (if any). /// - shellClient: The client responsible for executing shell commands. - init(projectURL: URL? = nil, shellClient: ShellClient) { + public init(projectURL: URL? = nil, shellClient: ShellClientProtocol) { self.projectURL = projectURL self.shellClient = shellClient } @@ -46,7 +48,7 @@ class GitConfigClient { /// - key: The configuration key to retrieve. /// - global: Whether to retrieve the value globally or locally. /// - Returns: The value as a type conforming to `GitConfigRepresentable`, or `nil` if not found. - func get(key: String, global: Bool = false) async throws -> T? { + public func get(key: String, global: Bool = false) async throws -> T? { let output = try await runConfigCommand(key, global: global) let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) return T(configValue: trimmedOutput) @@ -57,7 +59,7 @@ class GitConfigClient { /// - key: The configuration key to set. /// - value: The value to set, conforming to `GitConfigRepresentable`. /// - global: Whether to set the value globally or locally. - func set(key: String, value: T, global: Bool = false) async { + public func set(key: String, value: T, global: Bool = false) async { let shouldUnset: Bool if let boolValue = value as? Bool { shouldUnset = !boolValue diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigExtensions.swift b/CodeEditModules/Sources/CESourceControl/Client/GitConfigExtensions.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Client/GitConfigExtensions.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitConfigExtensions.swift diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift b/CodeEditModules/Sources/CESourceControl/Client/GitConfigRepresentable.swift similarity index 93% rename from CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift rename to CodeEditModules/Sources/CESourceControl/Client/GitConfigRepresentable.swift index 1eb55f5023..95cadc61e0 100644 --- a/CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift +++ b/CodeEditModules/Sources/CESourceControl/Client/GitConfigRepresentable.swift @@ -9,7 +9,7 @@ /// /// Conforming types must be able to initialize from a Git configuration string /// and convert their value back to a Git-compatible string representation. -protocol GitConfigRepresentable { +public protocol GitConfigRepresentable { /// Initializes a new instance from a Git configuration value string. /// - Parameter configValue: The configuration value string. init?(configValue: String) diff --git a/CodeEdit/Features/SourceControl/Clone/GitCheckoutBranchView.swift b/CodeEditModules/Sources/CESourceControl/Clone/GitCheckoutBranchView.swift similarity index 92% rename from CodeEdit/Features/SourceControl/Clone/GitCheckoutBranchView.swift rename to CodeEditModules/Sources/CESourceControl/Clone/GitCheckoutBranchView.swift index c3a4c3b001..d6f43ab666 100644 --- a/CodeEdit/Features/SourceControl/Clone/GitCheckoutBranchView.swift +++ b/CodeEditModules/Sources/CESourceControl/Clone/GitCheckoutBranchView.swift @@ -7,22 +7,24 @@ import Foundation import SwiftUI +import CodeEditCore -struct GitCheckoutBranchView: View { +public struct GitCheckoutBranchView: View { @Environment(\.dismiss) private var dismiss @StateObject private var viewModel: GitCheckoutBranchViewModel private var openDocument: (URL) -> Void - init( + public init( repoLocalPath: URL, + shellClient: ShellClientProtocol, openDocument: @escaping (URL) -> Void ) { - _viewModel = .init(wrappedValue: GitCheckoutBranchViewModel(repoPath: repoLocalPath)) + _viewModel = .init(wrappedValue: GitCheckoutBranchViewModel(repoPath: repoLocalPath, shellClient: shellClient)) self.openDocument = openDocument } - var body: some View { + public var body: some View { VStack(spacing: 8) { HStack { Image(nsImage: NSApp.applicationIconImage) diff --git a/CodeEdit/Features/SourceControl/Clone/ViewModels/GitCheckoutBranchViewModel.swift b/CodeEditModules/Sources/CESourceControl/Clone/GitCheckoutBranchViewModel.swift similarity index 79% rename from CodeEdit/Features/SourceControl/Clone/ViewModels/GitCheckoutBranchViewModel.swift rename to CodeEditModules/Sources/CESourceControl/Clone/GitCheckoutBranchViewModel.swift index df72ef2246..84ddc1cd28 100644 --- a/CodeEdit/Features/SourceControl/Clone/ViewModels/GitCheckoutBranchViewModel.swift +++ b/CodeEditModules/Sources/CESourceControl/Clone/GitCheckoutBranchViewModel.swift @@ -1,12 +1,14 @@ // -// GitCheckoutBranchView.swift +// GitCheckoutBranchViewModel.swift // CodeEdit // // Created by Albert Vinizhanau on 10/17/23. // import Foundation +import CodeEditCore +@MainActor class GitCheckoutBranchViewModel: ObservableObject { @Published var selectedBranch: GitBranch? @Published var branches: [GitBranch] = [] @@ -14,9 +16,9 @@ class GitCheckoutBranchViewModel: ObservableObject { let repoPath: URL private let gitClient: GitClient - init(repoPath: URL) { + init(repoPath: URL, shellClient: ShellClientProtocol) { self.repoPath = repoPath - gitClient = .init(directoryURL: repoPath, shellClient: .live()) + gitClient = .init(directoryURL: repoPath, shellClient: shellClient) } func loadBranches() async { diff --git a/CodeEdit/Features/SourceControl/Clone/GitCloneView.swift b/CodeEditModules/Sources/CESourceControl/Clone/GitCloneView.swift similarity index 89% rename from CodeEdit/Features/SourceControl/Clone/GitCloneView.swift rename to CodeEditModules/Sources/CESourceControl/Clone/GitCloneView.swift index 279032c4e2..db5ad7da12 100644 --- a/CodeEdit/Features/SourceControl/Clone/GitCloneView.swift +++ b/CodeEditModules/Sources/CESourceControl/Clone/GitCloneView.swift @@ -5,28 +5,31 @@ // Created by Aleksi Puttonen on 23.3.2022. // +import CodeEditCore import SwiftUI import Foundation import Combine -struct GitCloneView: View { +public struct GitCloneView: View { @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel: GitCloneViewModel = .init() + @StateObject private var viewModel: GitCloneViewModel private let openBranchView: (URL) -> Void private let openDocument: (URL) -> Void - init( + public init( + shellClient: ShellClientProtocol, openBranchView: @escaping (URL) -> Void, openDocument: @escaping (URL) -> Void ) { + _viewModel = .init(wrappedValue: GitCloneViewModel(shellClient: shellClient)) self.openBranchView = openBranchView self.openDocument = openDocument } - var body: some View { + public var body: some View { VStack(spacing: 8) { HStack(alignment: .top) { Image(nsImage: NSApp.applicationIconImage) @@ -99,7 +102,7 @@ struct GitCloneView: View { viewModel.cloneRepository { localPath in dismiss() - guard let gitClient = viewModel.gitClient else { return } + let gitClient = GitClient(directoryURL: localPath, shellClient: viewModel.shellClient) Task { let branches = ((try? await gitClient.getBranches()) ?? []) diff --git a/CodeEditModules/Sources/CESourceControl/Clone/GitCloneViewModel.swift b/CodeEditModules/Sources/CESourceControl/Clone/GitCloneViewModel.swift new file mode 100644 index 0000000000..e712d3976d --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Clone/GitCloneViewModel.swift @@ -0,0 +1,155 @@ +// +// GitCloneViewModel.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/17/23. +// + +import CodeEditCore +import Foundation +import AppKit + +@MainActor +class GitCloneViewModel: ObservableObject { + @Published var repoUrlStr = "" + @Published var isCloning: Bool = false + @Published var cloningProgress: GitClient.CloneProgress = .init(progress: 0, state: .initialState) + + var cloningTask: Task? + + let shellClient: ShellClientProtocol + private let cloner: RepositoryCloner + + init(shellClient: ShellClientProtocol) { + self.shellClient = shellClient + self.cloner = RepositoryCloner(shellClient: shellClient) + } + + /// Check if url is valid + /// - Parameter url: Url to check + /// - Returns: True if url is valid + func isValidUrl(url: String) -> Bool { + // Doing the same kind of check that Xcode does when cloning + let url = url.lowercased() + if url.starts(with: "http://") && url.count > 7 { + return true + } else if url.starts(with: "https://") && url.count > 8 { + return true + } else if url.starts(with: "git@") && url.count > 4 { + return true + } + return false + } + + /// Check if clipboard contains git url + func checkClipboard() { + if let url = NSPasteboard.general.pasteboardItems?.first?.string(forType: .string) { + if isValidUrl(url: url) { + self.repoUrlStr = url + } + } + } + + /// Clone repository + func cloneRepository(completionHandler: @escaping (URL) -> Void) { + do { + try cloner.verifyGitInstalled() + } catch { + showAlert(alertMsg: "Git installation not found.", infoText: error.localizedDescription) + return + } + + let parsed: (remoteUrl: URL, suggestedName: String) + do { + parsed = try cloner.parse(repoUrl: repoUrlStr) + } catch { + showAlert(alertMsg: "Invalid URL", infoText: error.localizedDescription) + return + } + + guard let localPath = getPath(saveName: parsed.suggestedName) else { + return + } + + let progressStream: AsyncThrowingMapSequence + do { + progressStream = try cloner.execute(remoteUrl: parsed.remoteUrl, localPath: localPath) + } catch { + showAlert(alertMsg: "Failed to clone", infoText: error.localizedDescription) + return + } + + cloningTask = Task(priority: .background) { [weak self] in + await self?.consumeProgress( + stream: progressStream, + localPath: localPath, + completionHandler: completionHandler + ) + } + } + + @MainActor + private func consumeProgress( + stream: AsyncThrowingMapSequence, + localPath: URL, + completionHandler: @escaping (URL) -> Void + ) async { + isCloning = true + defer { isCloning = false } + + do { + for try await progress in stream { + self.cloningProgress = progress + } + + if Task.isCancelled { + deleteTemporaryFolder(localPath: localPath) + return + } + + completionHandler(localPath) + } catch { + if let error = error as? GitClient.GitClientError { + showAlert(alertMsg: "Failed to clone", infoText: error.description) + } else { + showAlert(alertMsg: "Failed to clone", infoText: error.localizedDescription) + } + deleteTemporaryFolder(localPath: localPath) + } + } + + private func deleteTemporaryFolder(localPath: URL) { + do { + try cloner.cleanup(localPath: localPath) + } catch { + showAlert(alertMsg: "Failed to delete folder", infoText: "\(error)") + } + } + + private func getPath(saveName: String) -> URL? { + let dialog = NSSavePanel() + dialog.showsResizeIndicator = true + dialog.showsHiddenFiles = false + dialog.showsTagField = false + dialog.prompt = "Clone" + dialog.nameFieldStringValue = saveName + dialog.nameFieldLabel = "Clone as" + dialog.title = "Clone a Repository" + + guard dialog.runModal() == NSApplication.ModalResponse.OK, + let result = dialog.url else { + return nil + } + + return result + } + + private func showAlert(alertMsg: String, infoText: String) { + let alert = NSAlert() + alert.messageText = alertMsg + alert.informativeText = infoText + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } +} diff --git a/CodeEditModules/Sources/CESourceControl/Clone/RepositoryCloner.swift b/CodeEditModules/Sources/CESourceControl/Clone/RepositoryCloner.swift new file mode 100644 index 0000000000..a6cb24c991 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Clone/RepositoryCloner.swift @@ -0,0 +1,102 @@ +// +// RepositoryCloner.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/04/26. +// + +import CodeEditCore +import Foundation + +/// Validates and orchestrates a `git clone` operation, streaming progress to the caller. +final class RepositoryCloner { + private let shellClient: ShellClientProtocol + + init(shellClient: ShellClientProtocol) { + self.shellClient = shellClient + } + + enum Failure: Error, LocalizedError { + case gitNotInstalled + case invalidUrl + case directoryExists + case directoryCreationFailed(Error) + + var errorDescription: String? { + switch self { + case .gitNotInstalled: + return "Git installation not found. Ensure Git is installed on your system and try again." + case .invalidUrl: + return "Invalid repository URL." + case .directoryExists: + return "Directory already exists at the destination." + case .directoryCreationFailed(let error): + return "Failed to create folder: \(error.localizedDescription)" + } + } + } + + /// Parses and sanitizes the user-entered URL string, returning the remote URL and the suggested repo folder name. + func parse(repoUrl: String) throws -> (remoteUrl: URL, suggestedName: String) { + guard !repoUrl.isEmpty, let remoteUrl = URL(string: repoUrl) else { + throw Failure.invalidUrl + } + + var name = remoteUrl.lastPathComponent + if name.hasSuffix(".git") { + name.removeLast(4) + } + + return (remoteUrl, name) + } + + /// Verifies Git is available on the system. + func verifyGitInstalled() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["git"] + let pipe = Pipe() + process.standardOutput = pipe + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw Failure.gitNotInstalled + } + } catch let error as Failure { + throw error + } catch { + throw Failure.gitNotInstalled + } + } + + /// Creates the local directory and starts the clone, streaming progress. + /// On error or task cancellation, the caller is responsible for cleanup via ``cleanup(localPath:)``. + func execute( + remoteUrl: URL, + localPath: URL + ) throws -> AsyncThrowingMapSequence { + var isDir: ObjCBool = true + if FileManager.default.fileExists(atPath: localPath.relativePath, isDirectory: &isDir) { + throw Failure.directoryExists + } + + do { + try FileManager.default.createDirectory( + atPath: localPath.relativePath, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + throw Failure.directoryCreationFailed(error) + } + + let gitClient = GitClient(directoryURL: localPath, shellClient: shellClient) + return gitClient.cloneRepository(remoteUrl: remoteUrl, localPath: localPath) + } + + /// Removes a partially-cloned directory after a failure or cancellation. + func cleanup(localPath: URL) throws { + try FileManager.default.removeItem(atPath: localPath.relativePath) + } +} diff --git a/CodeEditModules/Sources/CESourceControl/HistoryInspector/GitHistoryInspectorContribution.swift b/CodeEditModules/Sources/CESourceControl/HistoryInspector/GitHistoryInspectorContribution.swift new file mode 100644 index 0000000000..5492e7a4a5 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/HistoryInspector/GitHistoryInspectorContribution.swift @@ -0,0 +1,32 @@ +// +// GitHistoryInspectorContribution.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 14/08/26. +// + +import CodeEditCore +import CodeEditUI +import SwiftUI + +/// CESourceControl's inspector tab. +/// +/// Takes the workspace's active-file read-model as an initialiser parameter: the history it shows +/// follows the editor selection, and `\.activeEditorState` is an app-shell environment key. +public struct GitHistoryInspectorContribution: WorkspacePanelContribution { + /// The single source of truth for this tab's id. The app-side `PanelTabID.gitHistory` + /// references this constant so the id is defined in exactly one place. + public static let tabID = "gitHistory" + + public let id = GitHistoryInspectorContribution.tabID + public let title = "History Inspector" + public let systemImage = "clock" + + private let activeEditorState: ActiveEditorState + + public init(activeEditorState: ActiveEditorState) { + self.activeEditorState = activeEditorState + } + + public var content: AnyView { AnyView(HistoryInspectorView(activeEditorState: activeEditorState)) } +} diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorItemView.swift similarity index 95% rename from CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift rename to CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorItemView.swift index 96bf256a28..307e6b143e 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift +++ b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorItemView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditUI +import CodeEditCore struct HistoryInspectorItemView: View { var commit: GitCommit diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorModel.swift similarity index 58% rename from CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift rename to CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorModel.swift index bb5ff4b8b3..0cd7f01790 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift +++ b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorModel.swift @@ -6,8 +6,18 @@ // import Foundation +import CodeEditSettings +import CodeEditCore +/// Main-actor isolated: it is created and driven entirely by `HistoryInspectorView`, and this +/// target compiles under Swift 6 strict concurrency, where passing a non-`Sendable` model into a +/// `Task` from the view is an error rather than the warning it was app-side. +@MainActor final class HistoryInspectorModel: ObservableObject { + /// The settings store. Assigned by `HistoryInspectorView` from the environment, alongside the + /// source-control manager — this model is created by a view and configured the same way. + var settingsAccessor: SettingsAccessing = DefaultSettingsReader() + private(set) var sourceControlManager: SourceControlManager? /// The base URL of the workspace @@ -33,7 +43,7 @@ final class HistoryInspectorModel: ObservableObject { func updateCommitHistory() async { guard let sourceControlManager, let fileURL else { - await setCommitHistory([]) + commitHistory = [] return } @@ -41,18 +51,14 @@ final class HistoryInspectorModel: ObservableObject { let commitHistory = try await sourceControlManager .gitClient .getCommitHistory( + branchName: nil, maxCount: 40, fileLocalPath: fileURL, - showMergeCommits: Settings.shared.preferences.sourceControl.git.showMergeCommitsPerFileLog + showMergeCommits: settingsAccessor.value(SourceControlSettings.self).git.showMergeCommitsPerFileLog ) - await setCommitHistory(commitHistory) + self.commitHistory = commitHistory } catch { - await setCommitHistory([]) + self.commitHistory = [] } } - - @MainActor - private func setCommitHistory(_ history: [GitCommit]) { - self.commitHistory = history - } } diff --git a/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorView.swift b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorView.swift new file mode 100644 index 0000000000..37855c87ac --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryInspectorView.swift @@ -0,0 +1,74 @@ +// +// HistoryInspectorView.swift +// CodeEdit +// +// Created by Nanashi Li on 2022/03/24. +// +import SwiftUI +import CodeEditSettings +import CodeEditUI +import CodeEditCore + +struct HistoryInspectorView: View { + @SettingsValue(SourceControlSettings.self, \.git.showMergeCommitsPerFileLog) + var showMergeCommitsPerFileLog + + @EnvironmentObject private var sourceControlManager: SourceControlManager + + /// The active-file read-model, injected rather than read from the environment: the + /// `\.activeEditorState` key is declared in the app shell and this view now ships in + /// `CESourceControl`. + private let activeEditorState: ActiveEditorState + + @EnvironmentObject private var settingsStore: PersistentSettingsStore + + @ObservedObject private var model: HistoryInspectorModel + + @State var selection: GitCommit? + + /// - Parameter activeEditorState: the workspace's active-file read-model; the history shown + /// follows its selection. + init(activeEditorState: ActiveEditorState) { + self.activeEditorState = activeEditorState + self.model = .init() + } + + var body: some View { + Group { + if model.sourceControlManager != nil { + VStack { + if model.commitHistory.isEmpty { + CEContentUnavailableView("No History") + } else { + List(selection: $selection) { + ForEach(model.commitHistory) { commit in + HistoryInspectorItemView(commit: commit, selection: $selection) + .tag(commit) + .listRowSeparator(.hidden) + } + } + } + } + } else { + CEContentUnavailableView("No Selection") + } + } + .onReceive(activeEditorState.selectedFilePublisher) { file in + Task { + await model.setFile(url: file?.url.path()) + } + } + .task { + // The model is created by this view, so this view configures it — the same shape as + // `setWorkspace` below. + model.settingsAccessor = settingsStore + await model.setWorkspace(sourceControlManager: sourceControlManager) + await model.setFile(url: activeEditorState.selectedFile?.url.path()) + } + .onChange(of: showMergeCommitsPerFileLog) { _, _ in + Task { + await model.updateCommitHistory() + } + } + } +} diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryPopoverView.swift similarity index 98% rename from CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift rename to CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryPopoverView.swift index 704eddb2c9..98ed0bb861 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift +++ b/CodeEditModules/Sources/CESourceControl/HistoryInspector/HistoryPopoverView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditUI +import CodeEditCore struct HistoryPopoverView: View { diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlAddExistingRemoteView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlAddExistingRemoteView.swift similarity index 85% rename from CodeEdit/Features/SourceControl/Views/SourceControlAddExistingRemoteView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlAddExistingRemoteView.swift index c891e381a0..621339c16b 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlAddExistingRemoteView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlAddExistingRemoteView.swift @@ -7,8 +7,11 @@ import SwiftUI -struct SourceControlAddExistingRemoteView: View { +public struct SourceControlAddExistingRemoteView: View { + public init() {} + @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @Environment(\.dismiss) private var dismiss @@ -21,7 +24,7 @@ struct SourceControlAddExistingRemoteView: View { @FocusState private var focusedField: FocusedField? - var body: some View { + public var body: some View { VStack(spacing: 0) { Form { Section("Add Remote") { @@ -71,8 +74,8 @@ struct SourceControlAddExistingRemoteView: View { Task { do { try await sourceControlManager.addRemote(name: name, location: location) - if sourceControlManager.pullSheetIsPresented || sourceControlManager.pushSheetIsPresented { - sourceControlManager.operationRemote = sourceControlManager.remotes.first( + if sourceControlViewModel.pullSheetIsPresented || sourceControlViewModel.pushSheetIsPresented { + sourceControlViewModel.operationRemote = sourceControlManager.remotes.first( where: { $0.name == name } ) } diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlFetchView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlFetchView.swift similarity index 90% rename from CodeEdit/Features/SourceControl/Views/SourceControlFetchView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlFetchView.swift index 2a43dd8c11..362482ecf5 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlFetchView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlFetchView.swift @@ -7,18 +7,19 @@ import SwiftUI -struct SourceControlFetchView: View { +public struct SourceControlFetchView: View { + public init() {} + @Environment(\.dismiss) private var dismiss @EnvironmentObject var sourceControlManager: SourceControlManager - @EnvironmentObject var workspace: WorkspaceDocument var projectName: String { - workspace.workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty" + sourceControlManager.workspaceURL.lastPathComponent } - var body: some View { + public var body: some View { VStack(spacing: 0) { HStack(alignment: .top, spacing: 20) { Image(nsImage: NSApp.applicationIconImage) diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlNewBranchView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlNewBranchView.swift similarity index 93% rename from CodeEdit/Features/SourceControl/Views/SourceControlNewBranchView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlNewBranchView.swift index 88fa176a9b..5cff001fad 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlNewBranchView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlNewBranchView.swift @@ -6,8 +6,10 @@ // import SwiftUI +import CodeEditCore + +public struct SourceControlNewBranchView: View { -struct SourceControlNewBranchView: View { @Environment(\.dismiss) var dismiss @@ -16,7 +18,11 @@ struct SourceControlNewBranchView: View { @State var name: String = "" @Binding var fromBranch: GitBranch? - var body: some View { + public init(fromBranch: Binding) { + self._fromBranch = fromBranch + } + + public var body: some View { if let branch = fromBranch ?? sourceControlManager.currentBranch { VStack(spacing: 0) { Form { diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlPullView.swift similarity index 74% rename from CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlPullView.swift index 45d0a8f970..40509fe437 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlPullView.swift @@ -5,27 +5,29 @@ // Created by Austin Condiff on 6/28/24. // +import CodeEditCore import SwiftUI -struct SourceControlPullView: View { +public struct SourceControlPullView: View { + public init() {} + @Environment(\.dismiss) private var dismiss @EnvironmentObject var sourceControlManager: SourceControlManager - - let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @State var loading: Bool = false @State var preferRebaseWhenPulling: Bool = false - var body: some View { + public var body: some View { VStack(spacing: 0) { Form { Section { RemoteBranchPicker( - branch: $sourceControlManager.operationBranch, - remote: $sourceControlManager.operationRemote, + branch: $sourceControlViewModel.operationBranch, + remote: $sourceControlViewModel.operationRemote, onSubmit: submit, canCreateBranch: false ) @@ -33,7 +35,7 @@ struct SourceControlPullView: View { Text("Pull remote changes from") } Section { - Toggle("Rebase local changes onto upstream changes", isOn: $sourceControlManager.operationRebase) + Toggle("Rebase local changes onto upstream changes", isOn: $sourceControlViewModel.operationRebase) } } .formStyle(.grouped) @@ -41,9 +43,10 @@ struct SourceControlPullView: View { .scrollContentBackground(.hidden) .onAppear { Task { - preferRebaseWhenPulling = try await gitConfig.get(key: "pull.rebase", global: true) ?? false + preferRebaseWhenPulling = try await sourceControlManager.gitConfig + .get(key: "pull.rebase", global: true) ?? false if preferRebaseWhenPulling { - sourceControlManager.operationRebase = true + sourceControlViewModel.operationRebase = true } } } @@ -83,13 +86,13 @@ struct SourceControlPullView: View { Task { do { if !sourceControlManager.changedFiles.isEmpty { - sourceControlManager.stashSheetIsPresented = true + sourceControlViewModel.stashSheetIsPresented = true } else { self.loading = true try await sourceControlManager.pull( - remote: sourceControlManager.operationRemote?.name ?? nil, - branch: sourceControlManager.operationBranch?.name ?? nil, - rebase: sourceControlManager.operationRebase + remote: sourceControlViewModel.operationRemote?.name ?? nil, + branch: sourceControlViewModel.operationBranch?.name ?? nil, + rebase: sourceControlViewModel.operationRebase ) self.loading = false dismiss() diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlPushView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlPushView.swift similarity index 74% rename from CodeEdit/Features/SourceControl/Views/SourceControlPushView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlPushView.swift index 8b280cb806..b7c19cbab1 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlPushView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlPushView.swift @@ -7,21 +7,24 @@ import SwiftUI -struct SourceControlPushView: View { +public struct SourceControlPushView: View { + public init() {} + @Environment(\.dismiss) private var dismiss @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @State var loading: Bool = false - var body: some View { + public var body: some View { VStack(spacing: 0) { Form { Section { RemoteBranchPicker( - branch: $sourceControlManager.operationBranch, - remote: $sourceControlManager.operationRemote, + branch: $sourceControlViewModel.operationBranch, + remote: $sourceControlViewModel.operationRemote, onSubmit: submit, canCreateBranch: true ) @@ -29,8 +32,8 @@ struct SourceControlPushView: View { Text("Push local changes to") } Section { - Toggle("Force", isOn: $sourceControlManager.operationForce) - Toggle("Include Tags", isOn: $sourceControlManager.operationIncludeTags) + Toggle("Force", isOn: $sourceControlViewModel.operationForce) + Toggle("Include Tags", isOn: $sourceControlViewModel.operationIncludeTags) } } .formStyle(.grouped) @@ -73,11 +76,11 @@ struct SourceControlPushView: View { do { self.loading = true try await sourceControlManager.push( - remote: sourceControlManager.operationRemote?.name ?? nil, - branch: sourceControlManager.operationBranch?.name ?? nil, + remote: sourceControlViewModel.operationRemote?.name ?? nil, + branch: sourceControlViewModel.operationBranch?.name ?? nil, setUpstream: sourceControlManager.currentBranch?.upstream == nil, - force: sourceControlManager.operationForce, - tags: sourceControlManager.operationIncludeTags + force: sourceControlViewModel.operationForce, + tags: sourceControlViewModel.operationIncludeTags ) self.loading = false dismiss() diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlRenameBranchView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlRenameBranchView.swift similarity index 91% rename from CodeEdit/Features/SourceControl/Views/SourceControlRenameBranchView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlRenameBranchView.swift index 65165caa4f..2176188175 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlRenameBranchView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlRenameBranchView.swift @@ -6,8 +6,10 @@ // import SwiftUI +import CodeEditCore + +public struct SourceControlRenameBranchView: View { -struct SourceControlRenameBranchView: View { @Environment(\.dismiss) var dismiss @@ -17,7 +19,11 @@ struct SourceControlRenameBranchView: View { @Binding var fromBranch: GitBranch? - var body: some View { + public init(fromBranch: Binding) { + self._fromBranch = fromBranch + } + + public var body: some View { if let branch = fromBranch ?? sourceControlManager.currentBranch { VStack(spacing: 0) { Form { diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlStashView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlStashView.swift similarity index 71% rename from CodeEdit/Features/SourceControl/Views/SourceControlStashView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlStashView.swift index 457b09a962..215edc2ca1 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlStashView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlStashView.swift @@ -1,5 +1,5 @@ // -// SourceControlAddRemoteView.swift +// SourceControlStashView.swift // CodeEdit // // Created by Austin Condiff on 11/17/23. @@ -7,15 +7,18 @@ import SwiftUI -struct SourceControlStashView: View { +public struct SourceControlStashView: View { + public init() {} + @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @Environment(\.dismiss) private var dismiss @State private var message: String = "" @State private var applyStashAfterOperation: Bool = false - var body: some View { + public var body: some View { VStack(spacing: 0) { Form { Section { @@ -27,8 +30,8 @@ struct SourceControlStashView: View { } header: { Text("Stash Changes") Group { - if sourceControlManager.pullSheetIsPresented - || sourceControlManager.switchToBranch != nil { + if sourceControlViewModel.pullSheetIsPresented + || sourceControlViewModel.switchToBranch != nil { Text("Your local repository has uncommitted changes that need to be stashed " + "before you can continue. Enter a description for your changes.") } else { @@ -39,8 +42,8 @@ struct SourceControlStashView: View { .multilineTextAlignment(.leading) .lineLimit(nil) } - if sourceControlManager.pullSheetIsPresented - || sourceControlManager.switchToBranch != nil { + if sourceControlViewModel.pullSheetIsPresented + || sourceControlViewModel.switchToBranch != nil { Section { Toggle("Apply stash after operation", isOn: $applyStashAfterOperation) } @@ -63,9 +66,9 @@ struct SourceControlStashView: View { submit() } label: { Text( - sourceControlManager.pullSheetIsPresented + sourceControlViewModel.pullSheetIsPresented ? "Stash and Pull" - : sourceControlManager.switchToBranch != nil + : sourceControlViewModel.switchToBranch != nil ? "Stash and Switch" : "Stash" ) @@ -85,17 +88,17 @@ struct SourceControlStashView: View { try await sourceControlManager.stashChanges(message: message) message = "" - if sourceControlManager.pullSheetIsPresented - || sourceControlManager.switchToBranch != nil { - if sourceControlManager.pullSheetIsPresented { + if sourceControlViewModel.pullSheetIsPresented + || sourceControlViewModel.switchToBranch != nil { + if sourceControlViewModel.pullSheetIsPresented { try await sourceControlManager.pull( - remote: sourceControlManager.operationRemote?.name, - branch: sourceControlManager.operationBranch?.name, - rebase: sourceControlManager.operationRebase + remote: sourceControlViewModel.operationRemote?.name, + branch: sourceControlViewModel.operationBranch?.name, + rebase: sourceControlViewModel.operationRebase ) } - if let branch = sourceControlManager.switchToBranch { + if let branch = sourceControlViewModel.switchToBranch { try await sourceControlManager.checkoutBranch(branch: branch) } @@ -110,10 +113,10 @@ struct SourceControlStashView: View { try await sourceControlManager.applyStashEntry(stashEntry: lastStashEntry) } - sourceControlManager.operationRemote = nil - sourceControlManager.operationBranch = nil - sourceControlManager.pullSheetIsPresented = false - sourceControlManager.switchToBranch = nil + sourceControlViewModel.operationRemote = nil + sourceControlViewModel.operationBranch = nil + sourceControlViewModel.pullSheetIsPresented = false + sourceControlViewModel.switchToBranch = nil } dismiss() diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlSwitchView.swift b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlSwitchView.swift similarity index 86% rename from CodeEdit/Features/SourceControl/Views/SourceControlSwitchView.swift rename to CodeEditModules/Sources/CESourceControl/Operations/SourceControlSwitchView.swift index ad18a67a6c..53e9578a1d 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlSwitchView.swift +++ b/CodeEditModules/Sources/CESourceControl/Operations/SourceControlSwitchView.swift @@ -1,22 +1,28 @@ // -// SourceControlFetchView.swift +// SourceControlSwitchView.swift // CodeEdit // // Created by Austin Condiff on 7/9/24. // import SwiftUI +import CodeEditCore + +public struct SourceControlSwitchView: View { -struct SourceControlSwitchView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var sourceControlManager: SourceControlManager - @EnvironmentObject var workspace: WorkspaceDocument + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel var branch: GitBranch - var body: some View { + public init(branch: GitBranch) { + self.branch = branch + } + + public var body: some View { VStack(spacing: 0) { HStack(alignment: .top, spacing: 20) { Image(nsImage: NSApp.applicationIconImage) @@ -64,7 +70,7 @@ struct SourceControlSwitchView: View { Task { do { if !sourceControlManager.changedFiles.isEmpty { - sourceControlManager.stashSheetIsPresented = true + sourceControlViewModel.stashSheetIsPresented = true } else { try await sourceControlManager.checkoutBranch(branch: branch) dismiss() diff --git a/CodeEditModules/Sources/CESourceControl/Settings/AccountsSettings.swift b/CodeEditModules/Sources/CESourceControl/Settings/AccountsSettings.swift new file mode 100644 index 0000000000..aa60b9074e --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/Settings/AccountsSettings.swift @@ -0,0 +1,45 @@ +// +// AccountsSettings.swift +// CodeEditModules/Settings +// +// Created by Nanashi Li on 2022/04/08. +// + +import CodeEditSettings +import Foundation + +/// The user's source-control accounts. +/// +/// Lives in `CESourceControl` rather than with the app-wide settings models because its entire +/// contents are source control: `sourceControlAccounts` holds `[SourceControlAccount]` and an SSH +/// key, and nothing else. The general-sounding name is historical. +public struct AccountsSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "accounts" + /// The list of git accounts the user has saved + @CodableDefault public var sourceControlAccounts: GitAccounts = .init() + + /// Default initializer + public init() {} + + public struct GitAccounts: Codable, Hashable { + /// This id will store the account name as the identifiable + @CodableDefault public var gitAccounts: [SourceControlAccount] = [] + + @CodableDefault public var sshKey = "" + + /// Default initializer + public init() {} + } +} + +// MARK: - Defaults + +public enum DefaultGitAccounts: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = AccountsSettings.GitAccounts() +} + +public enum DefaultEmptySourceControlAccounts: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue: [SourceControlAccount] = [] +} diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/Models/SourceControlAccount.swift b/CodeEditModules/Sources/CESourceControl/Settings/SourceControlAccount.swift similarity index 75% rename from CodeEdit/Features/Settings/Pages/AccountsSettings/Models/SourceControlAccount.swift rename to CodeEditModules/Sources/CESourceControl/Settings/SourceControlAccount.swift index f7b67898a7..ca7ce941c6 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/Models/SourceControlAccount.swift +++ b/CodeEditModules/Sources/CESourceControl/Settings/SourceControlAccount.swift @@ -5,27 +5,47 @@ // Created by Austin Condiff on 4/6/23. // -import SwiftUI +import Foundation -struct SourceControlAccount: Codable, Identifiable, Hashable { +public struct SourceControlAccount: Codable, Identifiable, Hashable { - var id: String - var name: String - var description: String - var provider: Provider - var serverURL: String + public var id: String + public var name: String + public var description: String + public var provider: Provider + public var serverURL: String // TODO: Should we use an enum instead of a boolean here: // If true we use the HTTP protocol else if false we use SSH - var urlProtocol: URLProtocol - var sshKey: String - var isTokenValid: Bool + public var urlProtocol: URLProtocol + public var sshKey: String + public var isTokenValid: Bool - enum URLProtocol: String, Codable, CaseIterable { + public init( + id: String, + name: String, + description: String, + provider: Provider, + serverURL: String, + urlProtocol: URLProtocol, + sshKey: String, + isTokenValid: Bool + ) { + self.id = id + self.name = name + self.description = description + self.provider = provider + self.serverURL = serverURL + self.urlProtocol = urlProtocol + self.sshKey = sshKey + self.isTokenValid = isTokenValid + } + + public enum URLProtocol: String, Codable, CaseIterable { case https = "HTTPS" case ssh = "SSH" } - enum Provider: Codable, CaseIterable, Identifiable { + public enum Provider: Codable, CaseIterable, Identifiable { case bitbucketCloud case bitbucketServer case github @@ -33,7 +53,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { case gitlab case gitlabSelfHosted - var id: String { + public var id: String { switch self { case .bitbucketCloud: return "bitbucketCloud" @@ -50,7 +70,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { } } - var name: String { + public var name: String { switch self { case .bitbucketCloud: return "BitBucket Cloud" @@ -67,7 +87,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { } } - var baseURL: URL? { + public var baseURL: URL? { switch self { case .bitbucketCloud: return URL(string: "https://www.bitbucket.com/")! @@ -84,7 +104,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { } } - var apiURL: URL? { + public var apiURL: URL? { switch self { case .bitbucketCloud: return URL(string: "https://api.bitbucket.org/2.0/")! @@ -101,18 +121,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { } } - var iconResource: ImageResource { - switch self { - case .bitbucketCloud, .bitbucketServer: - return .bitBucketIcon - case .github, .githubEnterprise: - return .gitHubIcon - case .gitlab, .gitlabSelfHosted: - return .gitLabIcon - } - } - - var authHelpURL: URL { + public var authHelpURL: URL { switch self { case .bitbucketCloud: return URL(string: "https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/")! @@ -130,7 +139,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { } } - var authType: AuthType { + public var authType: AuthType { switch self { case .bitbucketCloud: return .password @@ -148,7 +157,7 @@ struct SourceControlAccount: Codable, Identifiable, Hashable { } } - enum AuthType { + public enum AuthType { case token case password } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEditModules/Sources/CESourceControl/Settings/SourceControlSettings.swift similarity index 58% rename from CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift rename to CodeEditModules/Sources/CESourceControl/Settings/SourceControlSettings.swift index 309bdf0294..bab323863e 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEditModules/Sources/CESourceControl/Settings/SourceControlSettings.swift @@ -1,80 +1,60 @@ // -// SourceControlPreferences.swift +// SourceControlSettings.swift // CodeEditModules/Settings // // Created by Nanashi Li on 2022/04/08. // +import CodeEditSettings import Foundation -extension SettingsData { - /// The global settings for source control - struct SourceControlSettings: Codable, Hashable, SearchableSettingsPage { +/// The global settings for source control +public struct SourceControlSettings: SettingsSection { - var searchKeys: [String] { - [ - "General", - "Enable source control", - "Refresh local status automatically", - "Fetch and refresh server status automatically", - "Add and remove files automatically", - "Select files to commit automatically", - "Show source control changes", - "Include upstream changes", - "Comparison view", - "Source control navigator", - "Default branch name", - "Git", - "Author Name", - "Author Email", - "Prefer to rebase when pulling", - "Show merge commits in per-file log" - ] - .map { NSLocalizedString($0, comment: "") } - } + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "sourceControl" - /// The general source control settings - var general: SourceControlGeneral = .init() + /// The general source control settings + public var general: SourceControlGeneral = .init() - /// The source control git settings - var git: SourceControlGit = .init() + /// The source control git settings + public var git: SourceControlGit = .init() - /// Default initializer - init() {} + /// Default initializer + public init() {} - /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.general = try container.decodeIfPresent(SourceControlGeneral.self, forKey: .general) ?? .init() - self.git = try container.decodeIfPresent(SourceControlGit.self, forKey: .git) ?? .init() - } + /// Explicit decoder init for setting default values when key is not present in `JSON` + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.general = try container.decodeIfPresent(SourceControlGeneral.self, forKey: .general) ?? .init() + self.git = try container.decodeIfPresent(SourceControlGit.self, forKey: .git) ?? .init() } - struct SourceControlGeneral: Codable, Hashable { + public struct SourceControlGeneral: Codable, Hashable { /// Indicates whether or not the source control is active - var sourceControlIsEnabled: Bool = true + public var sourceControlIsEnabled: Bool = true /// Indicates whether the status should be refreshed locally without fetching updates from the server. - var refreshStatusLocally: Bool = true + public var refreshStatusLocally: Bool = true /// Indicates whether the application should automatically fetch updates from the server and refresh the status. - var fetchRefreshServerStatus: Bool = true + public var fetchRefreshServerStatus: Bool = true /// Indicates whether new and deleted files should be automatically staged for commit. - var addRemoveAutomatically: Bool = true + public var addRemoveAutomatically: Bool = true /// Indicates whether the application should automatically select files to commit. - var selectFilesToCommit: Bool = true + public var selectFilesToCommit: Bool = true /// Indicates whether or not to show the source control changes - var showSourceControlChanges: Bool = true + public var showSourceControlChanges: Bool = true /// Indicates whether or not we should include the upstream - var includeUpstreamChanges: Bool = true + public var includeUpstreamChanges: Bool = true /// Indicates whether or not we should open the reported feedback in the browser - var openFeedbackInBrowser: Bool = true + public var openFeedbackInBrowser: Bool = true /// The selected value of the comparison view - var revisionComparisonLayout: RevisionComparisonLayout = .localLeft + public var revisionComparisonLayout: RevisionComparisonLayout = .localLeft /// The selected value of the control navigator - var controlNavigatorOrder: ControlNavigatorOrder = .sortByName + public var controlNavigatorOrder: ControlNavigatorOrder = .sortByName /// Default initializer - init() {} + public init() {} /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.sourceControlIsEnabled = try container.decodeIfPresent( Bool.self, @@ -116,7 +96,7 @@ extension SettingsData { /// The style for comparison View /// - **localLeft**: Local Revision on Left Side /// - **localRight**: Local Revision on Right Side - enum RevisionComparisonLayout: String, Codable { + public enum RevisionComparisonLayout: String, Codable { case localLeft case localRight } @@ -124,18 +104,18 @@ extension SettingsData { /// The style for control Navigator /// - **sortName**: They are sorted by Name /// - **sortDate**: They are sorted by Date - enum ControlNavigatorOrder: String, Codable { + public enum ControlNavigatorOrder: String, Codable { case sortByName case sortByDate } - struct SourceControlGit: Codable, Hashable { + public struct SourceControlGit: Codable, Hashable { /// Indicates whether we should rebase when pulling commits - var showMergeCommitsPerFileLog: Bool = false + public var showMergeCommitsPerFileLog: Bool = false /// Default initializer - init() {} + public init() {} /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.showMergeCommitsPerFileLog = try container.decodeIfPresent( Bool.self, diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+Alerts.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+Alerts.swift new file mode 100644 index 0000000000..00bcb16cdb --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+Alerts.swift @@ -0,0 +1,53 @@ +// +// SourceControlManager+Alerts.swift +// CodeEdit +// +// Created by Nanashi Li on 2022/05/20. +// + +import AppKit + +/// Alert presentation helpers for source control error handling. +extension SourceControlManager { + /// Show alert for error + public func showAlertForError(title: String, error: Error) async { + if let error = error as? GitClient.GitClientError { + await showAlert(title: title, message: error.description) + return + } + + if let error = error as? LocalizedError { + var description = error.errorDescription ?? "" + if let failureReason = error.failureReason { + if description.isEmpty { + description += failureReason + } else { + description += "\n\n" + failureReason + } + } + + if let recoverySuggestion = error.recoverySuggestion { + if description.isEmpty { + description += recoverySuggestion + } else { + description += "\n\n" + recoverySuggestion + } + } + + await showAlert(title: title, message: description) + } else { + await showAlert(title: title, message: error.localizedDescription) + } + } + + func showAlert(title: String, message: String) async { + await MainActor.run { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+BranchOperations.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+BranchOperations.swift new file mode 100644 index 0000000000..5099a3bf43 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+BranchOperations.swift @@ -0,0 +1,58 @@ +// +// SourceControlManager+BranchOperations.swift +// CodeEdit +// +// Created by Austin Condiff on 7/2/24. +// + +import Foundation +import CodeEditCore + +/// Branch-related git operations. +extension SourceControlManager { + /// Refresh current branch + public func refreshCurrentBranch() async { + let currentBranch = try? await gitClient.getCurrentBranch() + await MainActor.run { + self.currentBranch = currentBranch + } + } + + /// Refresh branches + public func refreshBranches() async { + let branches = (try? await gitClient.getBranches(remote: nil)) ?? [] + await MainActor.run { + self.branches = branches + } + } + + /// Checkout branch + public func checkoutBranch(branch: GitBranch) async throws { + try await gitClient.checkoutBranch(branch, forceLocal: false, newName: nil) + await refreshBranches() + await refreshCurrentBranch() + } + + /// Create new branch, can be created only from local branch + public func newBranch(name: String, from: GitBranch) async throws { + try await gitClient.checkoutBranch(from, forceLocal: false, newName: name) + await refreshBranches() + await refreshCurrentBranch() + } + + /// Rename branch + public func renameBranch(oldName: String, newName: String) async throws { + try await gitClient.renameBranch(oldName: oldName, newName: newName) + await refreshBranches() + } + + /// Delete branch if it's local and not current + public func deleteBranch(branch: GitBranch) async throws { + if !branch.isLocal || branch == currentBranch { + return + } + + try await gitClient.deleteBranch(branch) + await refreshBranches() + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+FileEvents.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+FileEvents.swift new file mode 100644 index 0000000000..2a27048ca0 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+FileEvents.swift @@ -0,0 +1,83 @@ +// +// SourceControlManager+FileEvents.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import Combine +import CodeEditSettings +import Foundation +import CodeEditCore + +/// Interprets raw workspace filesystem events (published by the file manager) and +/// dispatches targeted git refreshes. This is where git-internals knowledge lives. +extension SourceControlManager { + /// The distinct git refreshes a set of changed paths can imply. + enum GitRefreshAction: CaseIterable { + case changedFiles, stash, branches, currentBranch, remotes, validate + } + + /// Pure classifier: maps changed workspace-relative paths to the set of git + /// refreshes they require. `workspaceRelativePath` is the workspace root's + /// `URL.relativePath` (used to anchor `.git/…` checks). + nonisolated static func gitRefreshActions( + for paths: [String], + workspaceRelativePath root: String + ) -> Set { + var actions: Set = [] + + let hasNonGitChanges = paths.contains(where: { !$0.contains(".git/") }) + let hasIndexChange = paths.contains("\(root)/.git/index") + if hasNonGitChanges || hasIndexChange { + actions.insert(.changedFiles) + } + if paths.contains("\(root)/.git/refs/stash") { + actions.insert(.stash) + } + if paths.contains(where: { $0.contains("\(root)/.git/refs/heads") }) { + actions.insert(.branches) + } + if paths.contains(where: { $0.contains("\(root)/.git/HEAD") }) { + actions.insert(.currentBranch) + } + if paths.contains("\(root)/.git/config") { + actions.insert(.remotes) + } + if paths.contains("\(root)/.git") { + actions.insert(.validate) + } + return actions + } + + /// Subscribe to workspace file events for this workspace. Call once from `init`. + func subscribeToWorkspaceFileEvents() { + eventBus.subscribe(WorkspaceFileEvent.self) + .filter { [weak self] in $0.workspaceURL == self?.workspaceURL } + .receive(on: RunLoop.main) + .sink { [weak self] event in + self?.handleWorkspaceFileEvent(event) + } + .store(in: &fileEventCancellables) + } + + private func handleWorkspaceFileEvent(_ event: WorkspaceFileEvent) { + switch event.kind { + case .childrenIndexed: + Task { await self.refreshAllChangedFiles() } + case let .filesystemChanged(paths): + let settings = settingsReader.value(SourceControlSettings.self).general + guard settings.sourceControlIsEnabled && settings.refreshStatusLocally else { return } + dispatch(Self.gitRefreshActions(for: paths, workspaceRelativePath: workspaceURL.relativePath)) + } + } + + private func dispatch(_ actions: Set) { + if actions.contains(.changedFiles) { Task { await self.refreshAllChangedFiles() } } + if actions.contains(.stash) { Task { try await self.refreshStashEntries() } } + if actions.contains(.branches) { Task { await self.refreshBranches() } } + if actions.contains(.currentBranch) { Task { await self.refreshCurrentBranch() } } + if actions.contains(.remotes) { Task { try await self.refreshRemotes() } } + if actions.contains(.validate) { Task { try await self.validate() } } + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+FileOperations.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+FileOperations.swift new file mode 100644 index 0000000000..55b5c087f7 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+FileOperations.swift @@ -0,0 +1,114 @@ +// +// SourceControlManager+FileOperations.swift +// CodeEdit +// +// Created by Austin Condiff on 7/2/24. +// + +import Foundation +import CodeEditCore + +/// File status, staging, committing, and discard operations. +extension SourceControlManager { + /// Refresh all changed files and refresh status in file manager + public func refreshAllChangedFiles() async { + do { + let status = try await gitClient.getStatus() + + // TODO: Unmerged changes + // status.unmergedChanges + + await setChangedFiles(status.changedFiles + status.untrackedFiles) + await refreshStatusInFileManager() + } catch GitClient.GitClientError.notGitRepository { + await setChangedFiles([]) + } catch { + logger.error("Error fetching git status: \(error)") + await setChangedFiles([]) + } + } + + /// Get all changed files for a commit + public func getCommitChangedFiles(commitSHA: String) async -> [GitChangedFile] { + do { + return try await gitClient.getCommitChangedFiles(commitSHA: commitSHA) + } catch { + logger.error("Error committing changed files: \(error)") + return [] + } + } + + /// Commit files selected by user + public func commit(message: String, details: String? = nil) async throws { + try await gitClient.commit(message: message, details: details) + + await self.refreshAllChangedFiles() + await self.refreshNumberOfUnsyncedCommits() + } + + /// Adds the given URLs to the staged changes. + /// - Parameter files: The files to stage. + public func add(_ files: [URL]) async throws { + try await gitClient.add(files) + } + + /// Removes the given URLs from the staged changes. + /// - Parameter files: The URLs to un-stage. + public func reset(_ files: [URL]) async throws { + try await gitClient.reset(files) + } + + /// Refresh number of unsynced commits + public func refreshNumberOfUnsyncedCommits() async { + let numberOfUnpushedCommits = (try? await gitClient.numberOfUnsyncedCommits()) ?? (ahead: 0, behind: 0) + + await MainActor.run { + self.numberOfUnsyncedCommits = numberOfUnpushedCommits + } + } + + /// Discard changes for file + public func discardChanges(for file: URL) { + Task { + do { + try await gitClient.discardChanges(for: file) + // TODO: Refresh content of active and unmodified document, + // requires CodeEditSourceEditor changes + } catch { + logger.error("Failed to discard changes for file (\(file.lastPathComponent): \(error)") + await showAlertForError(title: "Failed to discard changes", error: error) + } + } + } + + /// Discard changes for repository + public func discardAllChanges() { + Task { + do { + try await gitClient.discardAllChanges() + // TODO: Refresh content of active and unmodified document, + // requires CodeEditSourceEditor changes + } catch { + logger.error("Failed to discard changes: \(error)") + await showAlertForError(title: "Failed to discard changes", error: error) + } + } + } + + /// Set changed files on main actor + @MainActor + private func setChangedFiles(_ files: [GitChangedFile]) { + self.changedFiles = files + } + + /// Publish the current git status snapshot for the workspace's files. The + /// file manager applies these statuses onto its cached files. + @MainActor + private func refreshStatusInFileManager() { + let changed = Dictionary( + changedFiles.map { ($0.ceFileKey, $0.anyStatus()) }, + uniquingKeysWith: { _, latest in latest } + ) + eventBus.publish(GitStatusChangedEvent(workspaceURL: workspaceURL, changed: changed)) + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+RemoteOperations.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+RemoteOperations.swift new file mode 100644 index 0000000000..0562bb2314 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+RemoteOperations.swift @@ -0,0 +1,91 @@ +// +// SourceControlManager+RemoteOperations.swift +// CodeEdit +// +// Created by Austin Condiff on 7/2/24. +// + +import Foundation +import CodeEditCore + +/// Remote, fetch, pull, and push operations. +extension SourceControlManager { + /// Fetch from remote + public func fetch() async throws { + try await gitClient.fetchFromRemote() + await self.refreshNumberOfUnsyncedCommits() + } + + /// Pull changes from remote + public func pull(remote: String? = nil, branch: String? = nil, rebase: Bool = false) async throws { + try await gitClient.pullFromRemote(remote: remote, branch: branch, rebase: rebase) + await self.refreshNumberOfUnsyncedCommits() + } + + /// Push changes to remote + public func push( + remote: String? = nil, + branch: String? = nil, + setUpstream: Bool = false, + force: Bool = false, + tags: Bool = false + ) async throws { + guard currentBranch != nil else { return } + + try await gitClient.pushToRemote( + remote: remote, + branch: branch, + setUpstream: setUpstream, + force: force, + tags: tags + ) + + await refreshCurrentBranch() + await self.refreshNumberOfUnsyncedCommits() + } + + /// Get all remotes + public func refreshRemotes() async throws { + let remotes = (try? await gitClient.getRemotes()) ?? [] + await MainActor.run { + self.remotes = remotes + } + if !remotes.isEmpty { + try await self.refreshAllRemotesBranches() + } + } + + /// Refresh branches for all remotes + func refreshAllRemotesBranches() async throws { + for remote in remotes { + try await refreshRemoteBranches(remote: remote) + } + } + + /// Refresh branches for a specific remote + func refreshRemoteBranches(remote: GitRemote) async throws { + let branches = try await getRemoteBranches(remote: remote.name) + if let index = remotes.firstIndex(of: remote) { + await MainActor.run { + remotes[index].branches = branches + } + } + } + + /// Get branches for a specific remote + public func getRemoteBranches(remote: String) async throws -> [GitBranch] { + try await gitClient.getBranches(remote: remote) + } + + /// Add existing remote to git + public func addRemote(name: String, location: String) async throws { + try await gitClient.addRemote(name: name, location: location) + try await refreshRemotes() + } + + /// Delete remote + public func deleteRemote(remote: GitRemote) async throws { + try await gitClient.removeRemote(name: remote.name) + try await refreshRemotes() + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+Repository.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+Repository.swift new file mode 100644 index 0000000000..2bf21b2082 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+Repository.swift @@ -0,0 +1,24 @@ +// +// SourceControlManager+Repository.swift +// CodeEdit +// +// Created by Austin Condiff on 7/2/24. +// + +import Foundation + +/// Repository-level git operations. +extension SourceControlManager { + /// Validate repository + public func validate() async throws { + let isGitRepository = await gitClient.validate() + await MainActor.run { + self.isGitRepository = isGitRepository + } + } + + /// Initiate repository + public func initiate() async throws { + try await gitClient.initiate() + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager+StashOperations.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager+StashOperations.swift new file mode 100644 index 0000000000..8f888dfdd2 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager+StashOperations.swift @@ -0,0 +1,40 @@ +// +// SourceControlManager+StashOperations.swift +// CodeEdit +// +// Created by Austin Condiff on 7/2/24. +// + +import Foundation +import CodeEditCore + +/// Stash-related git operations. +extension SourceControlManager { + /// Refresh stash entries + public func refreshStashEntries() async throws { + let stashEntries = (try? await gitClient.stashList()) ?? [] + await MainActor.run { + self.stashEntries = stashEntries + } + } + + /// Stash changes + func stashChanges(message: String?) async throws { + try await gitClient.stash(message: message) + try await refreshStashEntries() + await refreshAllChangedFiles() + } + + /// Apply stash entry + public func applyStashEntry(stashEntry: GitStashEntry) async throws { + try await gitClient.applyStashEntry(stashEntry.index) + try await refreshStashEntries() + await refreshAllChangedFiles() + } + + /// Delete stash entry + public func deleteStashEntry(stashEntry: GitStashEntry) async throws { + try await gitClient.deleteStashEntry(stashEntry.index) + try await refreshStashEntries() + } +} diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlManager.swift b/CodeEditModules/Sources/CESourceControl/SourceControlManager.swift new file mode 100644 index 0000000000..ffe8495edc --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlManager.swift @@ -0,0 +1,89 @@ +// +// SourceControlManager.swift +// CodeEdit +// +// Created by Nanashi Li on 2022/05/20. +// + +import Combine +import Foundation +import OSLog +import CodeEditCore +import CodeEditSettings + +/// Stores git state for the workspace and delegates operations to ``GitClient``. +/// +/// Git operations are organized across domain-specific extensions: +/// - `+BranchOperations`: checkout, create, rename, delete branches +/// - `+StashOperations`: stash, apply, delete stash entries +/// - `+RemoteOperations`: fetch, pull, push, remote management +/// - `+FileOperations`: status, staging, commit, discard +/// - `+Repository`: validate, initiate +/// - `+Alerts`: error presentation helpers +@MainActor +public final class SourceControlManager: ObservableObject { + public let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "SourceControlManager") + + public let gitClient: GitClientProtocol + + /// Reads git configuration. Exposed so source-control views can consult config + /// (e.g. `pull.rebase`) without their own shell-client plumbing. + public let gitConfig: GitConfigClient + + /// The base URL of the workspace + public let workspaceURL: URL + + let eventBus: EventBus + let settingsReader: SettingsReading + var fileEventCancellables: Set = [] + + // MARK: - Git State + + /// A list of changed files + @Published public var changedFiles: [GitChangedFile] = [] + + /// Current branch + @Published public var currentBranch: GitBranch? + + /// All branches, local and remote + @Published public var branches: [GitBranch] = [] + + /// All remotes + @Published public var remotes: [GitRemote] = [] + + /// All stashed entries + @Published public var stashEntries: [GitStashEntry] = [] + + /// Number of unsynced commits with remote in current branch + @Published public var numberOfUnsyncedCommits: (ahead: Int, behind: Int) = (ahead: 0, behind: 0) + + /// Is project a git repository + @Published public var isGitRepository: Bool = false + + // MARK: - Computed Properties + + public var orderedLocalBranches: [GitBranch] { + var orderedBranches: [GitBranch] = [currentBranch].compactMap { $0 } + let otherBranches = branches.filter { $0.isLocal && $0 != currentBranch } + .sorted { $0.name.lowercased() < $1.name.lowercased() } + orderedBranches.append(contentsOf: otherBranches) + return orderedBranches + } + + // MARK: - Initialization + + public init( + workspaceURL: URL, + shellClient: ShellClientProtocol, + eventBus: EventBus, + settingsReader: SettingsReading + ) { + self.workspaceURL = workspaceURL + self.eventBus = eventBus + self.settingsReader = settingsReader + gitClient = GitClient(directoryURL: workspaceURL, shellClient: shellClient) + gitConfig = GitConfigClient(shellClient: shellClient) + subscribeToWorkspaceFileEvents() + Task { try? await validate() } + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesCommitView.swift similarity index 99% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesCommitView.swift index bdc273160f..4c37c944f1 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesCommitView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditUI struct SourceControlNavigatorChangesCommitView: View { @EnvironmentObject var sourceControlManager: SourceControlManager diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesList.swift similarity index 89% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesList.swift index 5aed2e3ced..2fe5d9b83f 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesList.swift @@ -7,11 +7,15 @@ import AppKit import SwiftUI +import CodeEditCore struct SourceControlNavigatorChangesList: View { - @EnvironmentObject var workspace: WorkspaceDocument @EnvironmentObject var sourceControlManager: SourceControlManager + /// Threaded from `SourceControlNavigatorView` rather than read from the environment: the + /// environment key lives in the app shell, and this view now ships in `CESourceControl`. + let navigator: WorkspaceNavigator + @State var selection = Set() var body: some View { @@ -72,11 +76,8 @@ struct SourceControlNavigatorChangesList: View { } private func openGitFile(_ file: GitChangedFile) { - guard let ceFile = workspace.workspaceFileManager?.getFile(file.ceFileKey, createIfNotFound: true) else { - return - } DispatchQueue.main.async { - workspace.editorManager?.openTab(item: ceFile, asTemporary: true) + navigator.open(fileAt: file.fileURL, asTemporary: true) } } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesView.swift similarity index 89% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesView.swift index 3d84d310b4..0894448d95 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorChangesView.swift @@ -6,10 +6,15 @@ // import SwiftUI +import CodeEditCore +import CodeEditUI struct SourceControlNavigatorChangesView: View { @EnvironmentObject var sourceControlManager: SourceControlManager + /// Passed down to the changes list, which needs it to open a changed file. + let navigator: WorkspaceNavigator + var hasRemotes: Bool { !sourceControlManager.remotes.isEmpty } @@ -48,7 +53,7 @@ struct SourceControlNavigatorChangesView: View { Divider() } if hasChanges { - SourceControlNavigatorChangesList() + SourceControlNavigatorChangesList(navigator: navigator) } else { CEContentUnavailableView("No Changes") } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorNoRemotesView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorNoRemotesView.swift similarity index 80% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorNoRemotesView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorNoRemotesView.swift index e6eb446395..187c8c468f 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorNoRemotesView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorNoRemotesView.swift @@ -8,7 +8,7 @@ import SwiftUI struct SourceControlNavigatorNoRemotesView: View { - @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel var body: some View { VStack(spacing: 0) { @@ -23,7 +23,7 @@ struct SourceControlNavigatorNoRemotesView: View { ) Spacer() Button("Add") { - sourceControlManager.addExistingRemoteSheetIsPresented = true + sourceControlViewModel.addExistingRemoteSheetIsPresented = true } } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorSyncView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorSyncView.swift similarity index 93% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorSyncView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorSyncView.swift index a17834a1d2..1c80b2e435 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorSyncView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Changes/SourceControlNavigatorSyncView.swift @@ -9,6 +9,7 @@ import SwiftUI struct SourceControlNavigatorSyncView: View { @ObservedObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @State private var isLoading: Bool = false var body: some View { @@ -38,7 +39,7 @@ struct SourceControlNavigatorSyncView: View { Spacer() if sourceControlManager.numberOfUnsyncedCommits.behind > 0 { Button { - sourceControlManager.pullSheetIsPresented = true + sourceControlViewModel.pullSheetIsPresented = true } label: { Text("Pull...") } @@ -46,7 +47,7 @@ struct SourceControlNavigatorSyncView: View { } else if sourceControlManager.numberOfUnsyncedCommits.ahead > 0 || currentBranch.upstream == nil { Button { - sourceControlManager.pushSheetIsPresented = true + sourceControlViewModel.pushSheetIsPresented = true } label: { Text("Push...") } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileLabel.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/GitChangedFileLabel.swift similarity index 52% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileLabel.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/GitChangedFileLabel.swift index 02a8769b7f..094e8263ee 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileLabel.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/GitChangedFileLabel.swift @@ -6,11 +6,10 @@ // import SwiftUI +import CodeEditCore +import CodeEditUI struct GitChangedFileLabel: View { - @EnvironmentObject private var workspace: WorkspaceDocument - @EnvironmentObject private var sourceControlManager: SourceControlManager - let file: GitChangedFile var body: some View { @@ -19,17 +18,15 @@ struct GitChangedFileLabel: View { .lineLimit(1) .truncationMode(.middle) } icon: { - if let ceFile = workspace.workspaceFileManager?.getFile(file.ceFileKey, createIfNotFound: true) { - Image(nsImage: ceFile.nsIcon) - .renderingMode(.template) - } else { - Image(systemName: FileIcon.fileIcon(fileType: nil)) - .renderingMode(.template) - } + FileIcon.spec(for: file.fileURL).image + .renderingMode(.template) } } } +// The label reads nothing but `file`, so the preview needs no environment. Building a +// `SourceControlManager` here previously forced a `ShellClient` import that this target — and this +// view — has no other use for. #Preview { Group { GitChangedFileLabel(file: GitChangedFile( @@ -38,8 +35,6 @@ struct GitChangedFileLabel: View { fileURL: URL(filePath: "/Users/CodeEdit/app.jsx"), originalFilename: nil )) - .environmentObject(SourceControlManager(workspaceURL: URL(filePath: "/Users/CodeEdit"), editorManager: .init())) - .environmentObject(WorkspaceDocument()) GitChangedFileLabel(file: GitChangedFile( status: .none, @@ -47,7 +42,5 @@ struct GitChangedFileLabel: View { fileURL: URL(filePath: "/Users/CodeEdit/app.jsx"), originalFilename: "app2.jsx" )) - .environmentObject(SourceControlManager(workspaceURL: URL(filePath: "/Users/CodeEdit"), editorManager: .init())) - .environmentObject(WorkspaceDocument()) }.padding() } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileListView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/GitChangedFileListView.swift similarity index 79% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileListView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/GitChangedFileListView.swift index 989e43818f..16f6792e39 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileListView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/GitChangedFileListView.swift @@ -6,13 +6,16 @@ // import SwiftUI +import CodeEditSettings +import CodeEditCore +import CodeEditUI /// A view to display a changed file's information in a list view. Optionally displays the staged status. struct GitChangedFileListView: View { - @AppSettings(\.general.fileIconStyle) + @SettingsValue(GeneralSettings.self, \.fileIconStyle) private var fileIconStyle - @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject private var sourceControlManager: SourceControlManager + @Binding private var changedFile: GitChangedFile @State private var staged: Bool @@ -57,21 +60,9 @@ struct GitChangedFileListView: View { } private var listItemTint: Color { - if let ceFile = workspace.workspaceFileManager?.getFile(changedFile.ceFileKey, createIfNotFound: true) { - iconForegroundColor(ceFile) - } else { - iconForegroundColor(nil) - } - } - - private func iconForegroundColor(_ file: CEWorkspaceFile?) -> Color { switch fileIconStyle { case .color: - if let file { - return file.iconColor - } else { - return FileIcon.iconColor(fileType: nil) - } + return FileIcon.spec(for: changedFile.fileURL).color case .monochrome: return Color("CoolGray") } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsHeaderView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitDetailsHeaderView.swift similarity index 99% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsHeaderView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitDetailsHeaderView.swift index c5993fa51e..3f574c253d 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsHeaderView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitDetailsHeaderView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore struct CommitDetailsHeaderView: View { var commit: GitCommit diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitDetailsView.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitDetailsView.swift index ed63905c1e..d35c322d32 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitDetailsView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditUI +import CodeEditCore struct CommitDetailsView: View { @EnvironmentObject var sourceControlManager: SourceControlManager diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitListItemView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitListItemView.swift similarity index 99% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitListItemView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitListItemView.swift index 5314cebbec..332334a24f 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitListItemView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/CommitListItemView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore struct CommitListItemView: View { diff --git a/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/Date+RelativeStringToNow.swift similarity index 78% rename from CodeEdit/Utils/Extensions/Date/Date+Formatted.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/Date+RelativeStringToNow.swift index 54ada62530..fc03744963 100644 --- a/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/Date+RelativeStringToNow.swift @@ -1,6 +1,6 @@ // -// Date+Formatted.swift -// CodeEditModules/CodeEditUtils +// Date+RelativeStringToNow.swift +// CodeEdit // // Created by Lukas Pistrol on 20.04.22. // @@ -36,14 +36,4 @@ extension Date { return formatter.string(from: self) } - - static var logFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss.SSSS" - return formatter - }() - - func logFormatted() -> String { - Self.logFormatter.string(from: self) - } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/SourceControlNavigatorHistoryView.swift similarity index 92% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/SourceControlNavigatorHistoryView.swift index 50898899f3..1a5e2647e9 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/SourceControlNavigatorHistoryView.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CodeEditSettings +import CodeEditUI +import CodeEditCore import CodeEditSymbols struct SourceControlNavigatorHistoryView: View { @@ -15,7 +18,7 @@ struct SourceControlNavigatorHistoryView: View { case error(error: Error) } - @AppSettings(\.sourceControl.git.showMergeCommitsPerFileLog) + @SettingsValue(SourceControlSettings.self, \.git.showMergeCommitsPerFileLog) var showMergeCommitsPerFileLog @EnvironmentObject var sourceControlManager: SourceControlManager @@ -33,7 +36,9 @@ struct SourceControlNavigatorHistoryView: View { .gitClient .getCommitHistory( branchName: sourceControlManager.currentBranch?.name, - showMergeCommits: Settings.shared.preferences.sourceControl.git.showMergeCommitsPerFileLog + maxCount: nil, + fileLocalPath: nil, + showMergeCommits: showMergeCommitsPerFileLog ) await MainActor.run { commitHistory = commits diff --git a/CodeEdit/Utils/Extensions/String/String+MD5.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/String+MD5.swift similarity index 100% rename from CodeEdit/Utils/Extensions/String/String+MD5.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/History/String+MD5.swift diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Models/RepoOutlineGroupItem.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/RepoOutlineGroupItem.swift similarity index 96% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Models/RepoOutlineGroupItem.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/RepoOutlineGroupItem.swift index a0e4549092..7529c94b69 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Models/RepoOutlineGroupItem.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/RepoOutlineGroupItem.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditCore struct RepoOutlineGroupItem: Hashable, Identifiable { enum ImageType: Hashable { diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryItem.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryItem.swift similarity index 81% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryItem.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryItem.swift index 003b285394..faa8126d2c 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryItem.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryItem.swift @@ -1,14 +1,15 @@ // -// SourceControlNavigatorRepositoriesItem.swift +// SourceControlNavigatorRepositoryItem.swift // CodeEdit // // Created by Austin Condiff on 11/29/23. // import SwiftUI +import CodeEditSettings struct SourceControlNavigatorRepositoryItem: View { - @AppSettings(\.general.fileIconStyle) + @SettingsValue(GeneralSettings.self, \.fileIconStyle) var fileIconStyle let item: RepoOutlineGroupItem @@ -57,7 +58,10 @@ struct SourceControlNavigatorRepositoryItem: View { } } .opacity(controlActiveState == .inactive ? 0.5 : 1) - .foregroundStyle(fileIconStyle == .color ? item.imageColor : Color.coolGray) + // `Color.coolGray` is an app-target asset symbol, which a package cannot see. Named + // lookup against the main bundle resolves the same asset and is what the sibling + // `GitChangedFileListView` — and `CEEditor` — already do for this colour. + .foregroundStyle(fileIconStyle == .color ? item.imageColor : Color("CoolGray")) }) .padding(.leading, 1) .padding(.vertical, -1) diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView+contextMenu.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView+contextMenu.swift similarity index 90% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView+contextMenu.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView+contextMenu.swift index b52ce306f1..380b2daeab 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView+contextMenu.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView+contextMenu.swift @@ -1,11 +1,12 @@ // -// SourceControlNavigatorRepositoriesView+contextMenu.swift +// SourceControlNavigatorRepositoryView+contextMenu.swift // CodeEdit // // Created by Austin Condiff on 11/29/23. // import SwiftUI +import CodeEditCore extension SourceControlNavigatorRepositoryView { func handleDelete(_ item: RepoOutlineGroupItem) { @@ -26,7 +27,7 @@ extension SourceControlNavigatorRepositoryView { @ViewBuilder func contextMenu(for item: RepoOutlineGroupItem, branch: GitBranch) -> some View { Button("Switch...") { - sourceControlManager.switchToBranch = branch + sourceControlViewModel.switchToBranch = branch } .disabled(item.branch == nil || sourceControlManager.currentBranch == item.branch) Divider() @@ -50,7 +51,7 @@ extension SourceControlNavigatorRepositoryView { .disabled(item.branch == nil || item.branch?.isRemote == true) Divider() Button("Add Existing Remote...") { - sourceControlManager.addExistingRemoteSheetIsPresented = true + sourceControlViewModel.addExistingRemoteSheetIsPresented = true } .disabled(item.id != "RemotesGroup") Divider() diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView+outlineGroupData.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView+outlineGroupData.swift similarity index 97% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView+outlineGroupData.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView+outlineGroupData.swift index cea00e0b9f..837ded3a7d 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView+outlineGroupData.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView+outlineGroupData.swift @@ -1,5 +1,5 @@ // -// SourceControlNavigatorRepositoriesView+outlineGroupData.swift +// SourceControlNavigatorRepositoryView+outlineGroupData.swift // CodeEdit // // Created by Austin Condiff on 11/29/23. diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView.swift index ecdbdea79b..9dd8f90fb3 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Repository/Views/SourceControlNavigatorRepositoryView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/Repository/SourceControlNavigatorRepositoryView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import CodeEditUI +import CodeEditCore import CodeEditSymbols struct SourceControlNavigatorRepositoryView: View { @@ -13,6 +15,7 @@ struct SourceControlNavigatorRepositoryView: View { var controlActiveState @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @State var selection = Set() @State var showNewBranch: Bool = false diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorContribution.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorContribution.swift new file mode 100644 index 0000000000..6ffbe301b4 --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorContribution.swift @@ -0,0 +1,39 @@ +// +// SourceControlNavigatorContribution.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 14/08/26. +// + +import CodeEditCore +import CodeEditUI +import SwiftUI + +/// CESourceControl's navigator tab. +/// +/// The package vends this itself; there is no app-side wrapper. The one dependency the tab cannot +/// resolve for itself — the command interface for opening a changed file — arrives as an +/// initialiser parameter from the composition root rather than through an environment key the +/// package does not own. +public struct SourceControlNavigatorContribution: WorkspacePanelContribution { + /// The single source of truth for this tab's id. The app-side `PanelTabID.sourceControl` + /// references this constant so the id is defined in exactly one place. + public static let tabID = "sourceControl" + + public let id = SourceControlNavigatorContribution.tabID + public let title = "Source Control" + public let systemImage = "vault" + + private let navigator: WorkspaceNavigator + + public init(navigator: WorkspaceNavigator) { + self.navigator = navigator + } + + public var content: AnyView { AnyView(SourceControlNavigatorView(navigator: navigator)) } + + /// The toolbar reads both source-control models. It no longer injects them itself: the bar is + /// rendered by the panel, which sits inside the navigator subtree that + /// `CodeEditSplitViewController` already supplies them to. + public var bottomView: AnyView? { AnyView(SourceControlNavigatorToolbarBottom()) } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorToolbarBottom.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorToolbarBottom.swift similarity index 80% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorToolbarBottom.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorToolbarBottom.swift index e476145451..14a7ec9fa5 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorToolbarBottom.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorToolbarBottom.swift @@ -6,10 +6,11 @@ // import SwiftUI +import CodeEditUI struct SourceControlNavigatorToolbarBottom: View { - @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject var sourceControlManager: SourceControlManager + @EnvironmentObject var sourceControlViewModel: SourceControlViewModel @State private var text = "" @@ -41,16 +42,16 @@ struct SourceControlNavigatorToolbarBottom: View { Menu { Button("Discard All Changes...") { if sourceControlManager.changedFiles.isEmpty { - sourceControlManager.noChangesToDiscardAlertIsPresented = true + sourceControlViewModel.noChangesToDiscardAlertIsPresented = true } else { - sourceControlManager.discardAllAlertIsPresented = true + sourceControlViewModel.discardAllAlertIsPresented = true } } Button("Stash Changes...") { if sourceControlManager.changedFiles.isEmpty { - sourceControlManager.noChangesToStashAlertIsPresented = true + sourceControlViewModel.noChangesToStashAlertIsPresented = true } else { - sourceControlManager.stashSheetIsPresented = true + sourceControlViewModel.stashSheetIsPresented = true } } } label: {} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorView.swift similarity index 54% rename from CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift rename to CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorView.swift index 749bf2bc7b..f9cb94aa2d 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift +++ b/CodeEditModules/Sources/CESourceControl/SourceControlNavigator/SourceControlNavigatorView.swift @@ -6,31 +6,42 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings +import CodeEditUI struct SourceControlNavigatorView: View { - @EnvironmentObject private var workspace: WorkspaceDocument + @EnvironmentObject private var sourceControlManager: SourceControlManager + @EnvironmentObject private var sourceControlViewModel: SourceControlViewModel - @AppSettings(\.sourceControl.general.fetchRefreshServerStatus) + @SettingsValue(SourceControlSettings.self, \.general.fetchRefreshServerStatus) var fetchRefreshServerStatus + /// The command interface used to open a changed file. Injected rather than read from the + /// environment, so the app shell stays the only place that knows where it comes from. + private let navigator: WorkspaceNavigator + + init(navigator: WorkspaceNavigator) { + self.navigator = navigator + } + var body: some View { - if let sourceControlManager = workspace.sourceControlManager { - VStack(spacing: 0) { - SourceControlNavigatorTabs() - .environmentObject(sourceControlManager) - .task { - do { - while true { - if fetchRefreshServerStatus { - try await sourceControlManager.fetch() - } - try await Task.sleep(for: .seconds(10)) + VStack(spacing: 0) { + SourceControlNavigatorTabs(navigator: navigator) + .environmentObject(sourceControlManager) + .environmentObject(sourceControlViewModel) + .task { + do { + while true { + if fetchRefreshServerStatus { + try await sourceControlManager.fetch() } - } catch { - // TODO: if source fetching fails, display message + try await Task.sleep(for: .seconds(10)) } + } catch { + // TODO: if source fetching fails, display message } - } + } } } } @@ -39,6 +50,8 @@ struct SourceControlNavigatorTabs: View { @EnvironmentObject var sourceControlManager: SourceControlManager @State private var selectedSection: Int = 0 + let navigator: WorkspaceNavigator + var body: some View { if sourceControlManager.isGitRepository { SegmentedControl( @@ -51,7 +64,7 @@ struct SourceControlNavigatorTabs: View { .padding(.horizontal, 8) Divider() if selectedSection == 0 { - SourceControlNavigatorChangesView() + SourceControlNavigatorChangesView(navigator: navigator) } if selectedSection == 1 { SourceControlNavigatorHistoryView() diff --git a/CodeEditModules/Sources/CESourceControl/SourceControlViewModel.swift b/CodeEditModules/Sources/CESourceControl/SourceControlViewModel.swift new file mode 100644 index 0000000000..700fd7294c --- /dev/null +++ b/CodeEditModules/Sources/CESourceControl/SourceControlViewModel.swift @@ -0,0 +1,87 @@ +// +// SourceControlViewModel.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 02/07/2026. +// + +import Foundation +import CodeEditCore + +/// Manages UI presentation state for the SourceControl feature. +/// +/// Domain state (branches, changed files, remotes, etc.) lives in ``SourceControlManager``. +/// This view model owns only ephemeral UI state: which sheets are presented, +/// alert flags, and the shared operation fields used by the push and pull sheets. +@MainActor +public final class SourceControlViewModel: ObservableObject { + public init() {} + + // MARK: - Sheet State + + /// Is the push sheet presented + @Published public var pushSheetIsPresented: Bool = false { + didSet { resetOperationFields() } + } + + /// Is the pull sheet presented + @Published public var pullSheetIsPresented: Bool = false { + didSet { resetOperationFields() } + } + + /// Is the fetch sheet presented + @Published public var fetchSheetIsPresented: Bool = false + + /// Is the stash sheet presented + @Published public var stashSheetIsPresented: Bool = false + + /// Is the remote sheet presented + @Published public var addExistingRemoteSheetIsPresented: Bool = false + + /// Branch to switch to + @Published public var switchToBranch: GitBranch? + + // MARK: - Operation Fields + + /// Branch selected for source control operations (shared between push and pull) + @Published public var operationBranch: GitBranch? + + /// Remote selected for source control operations + @Published public var operationRemote: GitRemote? + + /// Rebase boolean set for source control operations + @Published public var operationRebase: Bool = false + + /// Force boolean set for source control operations + @Published public var operationForce: Bool = false + + /// Include tags boolean set for source control operations + @Published public var operationIncludeTags: Bool = false + + // MARK: - Alert State + + /// Is discard all alert presented + @Published public var discardAllAlertIsPresented: Bool = false + + /// Is no changes to stage alert presented + @Published public var noChangesToStageAlertIsPresented: Bool = false + + /// Is no changes to unstage alert presented + @Published public var noChangesToUnstageAlertIsPresented: Bool = false + + /// Is no changes to stash alert presented + @Published public var noChangesToStashAlertIsPresented: Bool = false + + /// Is no changes to discard alert presented + @Published public var noChangesToDiscardAlertIsPresented: Bool = false + + // MARK: - Private + + private func resetOperationFields() { + operationBranch = nil + operationRemote = nil + operationRebase = false + operationForce = false + operationIncludeTags = false + } +} diff --git a/CodeEditModules/Sources/CETerminal/CETerminal.swift b/CodeEditModules/Sources/CETerminal/CETerminal.swift new file mode 100644 index 0000000000..4cef019b5f --- /dev/null +++ b/CodeEditModules/Sources/CETerminal/CETerminal.swift @@ -0,0 +1,11 @@ +// +// CETerminal.swift +// CETerminal +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +/// The CETerminal feature package: the SwiftTerm-backed terminal emulator +/// (`TerminalEmulatorView` and the `CETerminalView` family) and the task-running +/// engine (`TaskManager`, `CEActiveTask`) built on top of it. +enum CETerminal {} diff --git a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift b/CodeEditModules/Sources/CETerminal/Tasks/CEActiveTask.swift similarity index 68% rename from CodeEdit/Features/Tasks/Models/CEActiveTask.swift rename to CodeEditModules/Sources/CETerminal/Tasks/CEActiveTask.swift index 40cf6eb1ae..2f5b8a0afc 100644 --- a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift +++ b/CodeEditModules/Sources/CETerminal/Tasks/CEActiveTask.swift @@ -7,20 +7,22 @@ import SwiftUI import Combine -import SwiftTerm +@preconcurrency import SwiftTerm +import CodeEditCore /// Stores the state of a task once it's executed -class CEActiveTask: ObservableObject, Identifiable, Hashable { +@MainActor +public class CEActiveTask: ObservableObject, Identifiable, @preconcurrency Hashable { /// The current progress of the task. - @Published var output: CEActiveTaskTerminalView? + @Published public var output: CEActiveTaskTerminalView? var hasOutputBeenConfigured: Bool = false /// The status of the task. - @Published private(set) var status: CETaskStatus = .notRunning + @Published public private(set) var status: CETaskStatus = .notRunning /// The name of the associated task. - @ObservedObject var task: CETask + public let task: CETask /// Prevents tasks overwriting each other. /// Say a user cancels one task, then runs it immediately, the cancel message should show and then the @@ -31,19 +33,15 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { task.id.uuidString + "-" + activeTaskID.uuidString } - var workspaceURL: URL? + public var workspaceURL: URL? - private var cancellables = Set() + private let eventBus: EventBus - init(task: CETask) { + init(task: CETask, eventBus: EventBus) { self.task = task - - self.task.objectWillChange.sink { _ in - self.objectWillChange.send() - }.store(in: &cancellables) + self.eventBus = eventBus } - @MainActor func run(workspaceURL: URL?, shell: Shell? = nil) { self.workspaceURL = workspaceURL self.activeTaskID = UUID() // generate a new ID for this run @@ -57,7 +55,6 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { output = view } - @MainActor func handleProcessFinished(terminationStatus: Int32) { // Shells add 128 to non-zero exit codes. var terminationStatus = terminationStatus @@ -106,16 +103,14 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { deleteStatusTaskNotification() } - @MainActor - func suspend() { + public func suspend() { if let shellPID = output?.runningPID(), status == .running { kill(shellPID, SIGSTOP) updateTaskStatus(to: .stopped) } } - @MainActor - func resume() { + public func resume() { if let shellPID = output?.runningPID(), status == .running { kill(shellPID, SIGCONT) updateTaskStatus(to: .running) @@ -140,68 +135,49 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { } } - @MainActor - func clearOutput() { + public func clearOutput() { output?.terminal.resetToInitialState() output?.feed(text: "") } private func createStatusTaskNotification() { - let userInfo: [String: Any] = [ - "id": taskId, - "action": "createWithPriority", - "title": "Running \(self.task.name)", - "message": "Running your task: \(self.task.name).", - "isLoading": true, - "workspace": workspaceURL as Any - ] - - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) + eventBus.publish(TaskNotificationEvent( + .createWithPriority(TaskNotificationModel( + id: taskId, + title: "Running \(self.task.name)", + message: "Running your task: \(self.task.name).", + isLoading: true + )), + workspace: workspaceURL + )) } private func deleteStatusTaskNotification() { - let deleteInfo: [String: Any] = [ - "id": taskId, - "action": "deleteWithDelay", - "delay": 3.0, - "workspace": workspaceURL as Any - ] - - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) + eventBus.publish(TaskNotificationEvent( + .deleteWithDelay(id: taskId, delay: 3.0), + workspace: workspaceURL + )) } private func updateTaskNotification(title: String? = nil, message: String? = nil, isLoading: Bool? = nil) { - var userInfo: [String: Any] = [ - "id": taskId, - "action": "update", - "workspace": workspaceURL as Any - ] - if let title { - userInfo["title"] = title - } - if let message { - userInfo["message"] = message - } - if let isLoading { - userInfo["isLoading"] = isLoading - } - - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) + eventBus.publish(TaskNotificationEvent( + .update(id: taskId, title: title, message: message, isLoading: isLoading), + workspace: workspaceURL + )) } - @MainActor func updateTaskStatus(to taskStatus: CETaskStatus) { self.status = taskStatus } - static func == (lhs: CEActiveTask, rhs: CEActiveTask) -> Bool { + public static func == (lhs: CEActiveTask, rhs: CEActiveTask) -> Bool { return lhs.output == rhs.output && lhs.status == rhs.status && lhs.output?.process.shellPid == rhs.output?.process.shellPid && lhs.task == rhs.task } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(output) hasher.combine(status) hasher.combine(task) diff --git a/CodeEdit/Features/Tasks/Models/CETaskStatus.swift b/CodeEditModules/Sources/CETerminal/Tasks/CETaskStatus.swift similarity index 56% rename from CodeEdit/Features/Tasks/Models/CETaskStatus.swift rename to CodeEditModules/Sources/CETerminal/Tasks/CETaskStatus.swift index 7a42262fc7..ce81b820b7 100644 --- a/CodeEdit/Features/Tasks/Models/CETaskStatus.swift +++ b/CodeEditModules/Sources/CETerminal/Tasks/CETaskStatus.swift @@ -8,18 +8,20 @@ import SwiftUI /// Enum to represent a task's status -enum CETaskStatus { - // default state +public enum CETaskStatus { + /// The task has not been started yet. case notRunning - // User suspended the process + /// The user suspended the task's process. case stopped + /// The task's process is currently executing. case running - // Processes finished with an error + /// The task's process exited with an error. case failed - // Processes finished without an error + /// The task's process exited successfully. case finished - var color: Color { + /// The color used to represent this status in task indicators throughout the UI. + public var color: Color { switch self { case .notRunning: return Color.gray case .stopped: return Color.yellow diff --git a/CodeEdit/Features/Tasks/TaskManager.swift b/CodeEditModules/Sources/CETerminal/Tasks/TaskManager.swift similarity index 75% rename from CodeEdit/Features/Tasks/TaskManager.swift rename to CodeEditModules/Sources/CETerminal/Tasks/TaskManager.swift index f185320cab..0d6575292c 100644 --- a/CodeEdit/Features/Tasks/TaskManager.swift +++ b/CodeEditModules/Sources/CETerminal/Tasks/TaskManager.swift @@ -7,31 +7,36 @@ import SwiftUI import Combine +import CodeEditCore /// This class handles the execution of tasks @MainActor -class TaskManager: ObservableObject { - @Published var activeTasks: [UUID: CEActiveTask] = [:] - @Published var selectedTaskID: UUID? - @Published var taskShowingOutput: UUID? +public class TaskManager: ObservableObject { + @Published public var activeTasks: [UUID: CEActiveTask] = [:] + @Published public var selectedTaskID: UUID? + @Published public var taskShowingOutput: UUID? - @ObservedObject var workspaceSettings: CEWorkspaceSettingsData + private let tasksConfiguration: any TasksConfigurationProviding private var workspaceURL: URL? private var settingsListener: AnyCancellable? - init(workspaceSettings: CEWorkspaceSettingsData, workspaceURL: URL?) { + private let eventBus: EventBus + + public init(tasksConfiguration: any TasksConfigurationProviding, workspaceURL: URL?, eventBus: EventBus) { + self.eventBus = eventBus self.workspaceURL = workspaceURL - self.workspaceSettings = workspaceSettings + self.tasksConfiguration = tasksConfiguration - settingsListener = workspaceSettings.$tasks + settingsListener = tasksConfiguration.tasksPublisher + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateSelectedTaskID() } } - var selectedTask: CETask? { + public var selectedTask: CETask? { if let selectedTaskID { return availableTasks.first { $0.id == selectedTaskID } } else { @@ -47,27 +52,27 @@ class TaskManager: ObservableObject { return nil } - var availableTasks: [CETask] { - return workspaceSettings.tasks + public var availableTasks: [CETask] { + return tasksConfiguration.tasks } - func taskStatus(taskID: UUID) -> CETaskStatus { + public func taskStatus(taskID: UUID) -> CETaskStatus { return self.activeTasks[taskID]?.status ?? .notRunning } - func updateSelectedTaskID() { + public func updateSelectedTaskID() { guard selectedTask == nil else { return } selectedTaskID = availableTasks.first?.id } - func executeActiveTask() { - guard let task = workspaceSettings.tasks.first(where: { $0.id == selectedTaskID }) else { return } + public func executeActiveTask() { + guard let task = tasksConfiguration.tasks.first(where: { $0.id == selectedTaskID }) else { return } Task { await runTask(task: task) } } - func runTask(task: CETask) async { + public func runTask(task: CETask) async { // A process can only be started once, that means we have to renew the Process and Pipe // but don't initialize a new object. if let activeTask = activeTasks[task.id] { @@ -79,7 +84,7 @@ class TaskManager: ObservableObject { } activeTask.run(workspaceURL: workspaceURL) } else { - let runningTask = CEActiveTask(task: task) + let runningTask = CEActiveTask(task: task, eventBus: eventBus) runningTask.run(workspaceURL: workspaceURL) await MainActor.run { activeTasks[task.id] = runningTask @@ -87,7 +92,7 @@ class TaskManager: ObservableObject { } } - func terminateActiveTask() { + public func terminateActiveTask() { guard let taskID = selectedTaskID else { return } @@ -103,7 +108,7 @@ class TaskManager: ObservableObject { /// this method does nothing. /// /// - Parameter taskID: The ID of the task to suspend. - func suspendTask(taskID: UUID) { + public func suspendTask(taskID: UUID) { if let activeTask = activeTasks[taskID] { activeTask.suspend() } @@ -115,7 +120,7 @@ class TaskManager: ObservableObject { /// this method does nothing. /// /// - Parameter taskID: The ID of the task to resume. - func resumeTask(taskID: UUID) { + public func resumeTask(taskID: UUID) { if let activeTask = activeTasks[taskID] { activeTask.resume() } @@ -131,7 +136,7 @@ class TaskManager: ObservableObject { /// or if the task is not currently running, this method does nothing. /// /// - Parameter taskID: The ID of the task to terminate. - func terminateTask(taskID: UUID) { + public func terminateTask(taskID: UUID) { if let activeTask = activeTasks[taskID] { activeTask.terminate() } @@ -148,19 +153,19 @@ class TaskManager: ObservableObject { /// this method does nothing. /// /// - Parameter taskID: The ID of the task to interrupt. - func interruptTask(taskID: UUID) { + public func interruptTask(taskID: UUID) { if let activeTask = activeTasks[taskID] { activeTask.interrupt() } } - func stopAllTasks() { + public func stopAllTasks() { for (id, _) in activeTasks { interruptTask(taskID: id) } } - func deleteTask(taskID: UUID) { + public func deleteTask(taskID: UUID) { terminateTask(taskID: taskID) activeTasks.removeValue(forKey: taskID) } diff --git a/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/CEActiveTaskTerminalView.swift similarity index 86% rename from CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/CEActiveTaskTerminalView.swift index 1e721d6a34..d075cb1abc 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/CEActiveTaskTerminalView.swift @@ -5,32 +5,34 @@ // Created by Khan Winter on 7/14/25. // +import CodeEditCore import AppKit +import CodeEditSettings import SwiftTerm -class CEActiveTaskTerminalView: CELocalShellTerminalView { +public class CEActiveTaskTerminalView: CELocalShellTerminalView { var activeTask: CEActiveTask var isUserCommandRunning: Bool { activeTask.status == .running || activeTask.status == .stopped } - init(activeTask: CEActiveTask) { + init(activeTask: CEActiveTask, settings: TerminalSettings = TerminalSettings()) { self.activeTask = activeTask - super.init(frame: .zero) + super.init(frame: .zero, settings: settings) } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override func startProcess( + override public func startProcess( workspaceURL url: URL?, shell: Shell? = nil, environment: [String] = [], interactive: Bool = true ) { - let terminalSettings = Settings.shared.preferences.terminal + let terminalSettings = settings var terminalEnvironment: [String] = Terminal.getEnvironmentVariables() terminalEnvironment.append("TERM_PROGRAM=CodeEditApp_Terminal") @@ -59,7 +61,7 @@ class CEActiveTaskTerminalView: CELocalShellTerminalView { ) } - override func processTerminated(_ source: LocalProcess, exitCode: Int32?) { + override public func processTerminated(_ source: LocalProcess, exitCode: Int32?) { activeTask.handleProcessFinished(terminationStatus: exitCode ?? 1) } diff --git a/CodeEdit/Features/TerminalEmulator/Views/CELocalShellTerminalView.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/CELocalShellTerminalView.swift similarity index 75% rename from CodeEdit/Features/TerminalEmulator/Views/CELocalShellTerminalView.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/CELocalShellTerminalView.swift index e755839c37..e814b0ceaa 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/CELocalShellTerminalView.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/CELocalShellTerminalView.swift @@ -5,8 +5,10 @@ // Created by Khan Winter on 8/7/24. // +import CodeEditCore import AppKit -import SwiftTerm +import CodeEditSettings +@preconcurrency import SwiftTerm import Foundation /// # Dev Note (please read) @@ -20,7 +22,11 @@ import Foundation /// This has now been updated so that it differs from `LocalProcessTerminalView` in enough important ways that it /// should not be removed in the future even if SwiftTerm has a change in behavior. -protocol CELocalShellTerminalViewDelegate: AnyObject { +/// `@MainActor`: `LocalProcess`'s exit-monitor and read-queue callbacks are dispatched on +/// `DispatchQueue.main` (SwiftTerm's default when no custom queue is supplied), and +/// `TerminalViewDelegate` callbacks originate from AppKit view events — both always on main. +@MainActor +public protocol CELocalShellTerminalViewDelegate: AnyObject { /// This method is invoked to notify that the terminal has been resized to the specified number of columns and rows /// the user interface code might try to adjust the containing scroll view, or if it is a top level window, the /// window itself @@ -48,15 +54,24 @@ protocol CELocalShellTerminalViewDelegate: AnyObject { // MARK: - CELocalShellTerminalView -class CELocalShellTerminalView: CETerminalView, TerminalViewDelegate, LocalProcessDelegate { - var process: LocalProcess! +@MainActor +public class CELocalShellTerminalView: CETerminalView, @preconcurrency TerminalViewDelegate, + @preconcurrency LocalProcessDelegate { + public var process: LocalProcess! - override public init(frame: CGRect) { + /// The terminal settings this view was last configured with. Set at construction and kept + /// current by ``apply(settings:)``, which `TerminalEmulatorView` calls from `updateNSView` so a + /// running terminal picks up changes without being reopened. + public internal(set) var settings: TerminalSettings + + public init(frame: CGRect, settings: TerminalSettings = TerminalSettings()) { + self.settings = settings super.init(frame: frame) setup() } public required init?(coder: NSCoder) { + self.settings = TerminalSettings() super.init(coder: coder) setup() } @@ -80,7 +95,7 @@ class CELocalShellTerminalView: CETerminalView, TerminalViewDelegate, LocalProce environment: [String] = [], interactive: Bool = true ) { - let terminalSettings = Settings.shared.preferences.terminal + let terminalSettings = settings var terminalEnvironment: [String] = Terminal.getEnvironmentVariables() terminalEnvironment.append("TERM_PROGRAM=CodeEditApp_Terminal") @@ -118,8 +133,32 @@ class CELocalShellTerminalView: CETerminalView, TerminalViewDelegate, LocalProce } } + /// Re-applies the parts of ``TerminalSettings`` this view can act on by itself: the cursor + /// style/blink and the option-as-meta key mapping. Font and theme-derived colours stay with + /// `TerminalEmulatorView`, which also needs the current theme and text-editing font settings. + /// + /// `TerminalEmulatorView.updateNSView` calls this on every settings change so a running + /// terminal reflects new settings without being reopened. + public func apply(settings: TerminalSettings) { + self.settings = settings + optionAsMetaKey = settings.optionAsMeta + cursorStyleChanged(source: getTerminal(), newStyle: Self.cursorStyle(for: settings)) + } + + /// The `SwiftTerm.CursorStyle` for the given terminal settings' cursor shape and blink state. + static func cursorStyle(for settings: TerminalSettings) -> CursorStyle { + switch settings.cursorStyle { + case .block: + return settings.cursorBlink ? .blinkBlock : .steadyBlock + case .underline: + return settings.cursorBlink ? .blinkUnderline : .steadyUnderline + case .bar: + return settings.cursorBlink ? .blinkBar : .steadyBar + } + } + /// Returns a string of a shell path to use - func getShell(_ shellType: Shell?, userSetting: SettingsData.TerminalShell) -> (Shell, String)? { + func getShell(_ shellType: Shell?, userSetting: TerminalSettings.Shell) -> (Shell, String)? { if let shellType { return (shellType, shellType.defaultPath) } diff --git a/CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/CETerminalView.swift similarity index 93% rename from CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/CETerminalView.swift index 3541da9a52..d79dfca84a 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/CETerminalView.swift @@ -10,8 +10,8 @@ import AppKit /// # Please see dev note in ``CELocalShellTerminalView``! -class CETerminalView: TerminalView { - override func setFrameSize(_ newSize: NSSize) { +public class CETerminalView: TerminalView { + override public func setFrameSize(_ newSize: NSSize) { if newSize != .zero { super.setFrameSize(newSize) } diff --git a/CodeEdit/Utils/Extensions/LocalProcess/LocalProcess+sendText.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/LocalProcess+sendText.swift similarity index 100% rename from CodeEdit/Utils/Extensions/LocalProcess/LocalProcess+sendText.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/LocalProcess+sendText.swift diff --git a/CodeEdit/Features/TerminalEmulator/Model/CurrentUser.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/CurrentUser.swift similarity index 100% rename from CodeEdit/Features/TerminalEmulator/Model/CurrentUser.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/CurrentUser.swift diff --git a/CodeEdit/Features/TerminalEmulator/Model/Shell.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/Shell.swift similarity index 96% rename from CodeEdit/Features/TerminalEmulator/Model/Shell.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/Shell.swift index 9dd6257e30..bc67d3d0f8 100644 --- a/CodeEdit/Features/TerminalEmulator/Model/Shell.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/Shell.swift @@ -1,5 +1,5 @@ // -// ShellIntegration.swift +// Shell.swift // CodeEdit // // Created by Khan Winter on 6/1/24. @@ -8,7 +8,7 @@ import Foundation /// Shells supported by CodeEdit -enum Shell: String, CaseIterable { +public enum Shell: String, CaseIterable { case bash case zsh diff --git a/CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/ShellIntegration.swift similarity index 100% rename from CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/Shell/ShellIntegration.swift diff --git a/CodeEdit/Utils/Extensions/SwiftTerm/Color/SwiftTerm+Color+Init.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/SwiftTerm+Color+Init.swift similarity index 100% rename from CodeEdit/Utils/Extensions/SwiftTerm/Color/SwiftTerm+Color+Init.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/SwiftTerm+Color+Init.swift diff --git a/CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalCache.swift similarity index 77% rename from CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalCache.swift index f210085249..0dcd584c9d 100644 --- a/CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalCache.swift @@ -10,8 +10,10 @@ import SwiftTerm /// Stores a mapping of ID -> terminal view for reusing terminal views. /// This allows terminal views to continue to receive data even when not in the view hierarchy. -final class TerminalCache { - static let shared: TerminalCache = TerminalCache() +@MainActor +public final class TerminalCache { + /// The single cache shared by all terminal views in the app. + public static let shared: TerminalCache = TerminalCache() /// The cache of terminal views. private var terminals: [UUID: CELocalShellTerminalView] @@ -23,7 +25,7 @@ final class TerminalCache { /// Get a cached terminal view. /// - Parameter id: The ID of the terminal. /// - Returns: The existing terminal, if it exists. - func getTerminalView(_ id: UUID) -> CELocalShellTerminalView? { + public func getTerminalView(_ id: UUID) -> CELocalShellTerminalView? { terminals[id] } @@ -37,7 +39,7 @@ final class TerminalCache { /// Remove any view associated with the terminal id. /// - Parameter id: The ID of the terminal. - func removeCachedView(_ id: UUID) { + public func removeCachedView(_ id: UUID) { terminals[id] = nil } } diff --git a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalEmulatorView+Coordinator.swift similarity index 70% rename from CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalEmulatorView+Coordinator.swift index ad290c4f44..5fa695ea38 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalEmulatorView+Coordinator.swift @@ -9,7 +9,7 @@ import SwiftUI import SwiftTerm extension TerminalEmulatorView { - final class Coordinator: NSObject, CELocalShellTerminalViewDelegate { + public final class Coordinator: NSObject, CELocalShellTerminalViewDelegate { private let terminalID: UUID public var onTitleChange: (_ title: String) -> Void @@ -22,15 +22,15 @@ extension TerminalEmulatorView { super.init() } - func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + public func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} - func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int) {} + public func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int) {} - func setTerminalTitle(source: CETerminalView, title: String) { + public func setTerminalTitle(source: CETerminalView, title: String) { onTitleChange(title) } - func processTerminated(source: TerminalView, exitCode: Int32?) { + public func processTerminated(source: TerminalView, exitCode: Int32?) { guard let exitCode else { return } diff --git a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift b/CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalEmulatorView.swift similarity index 62% rename from CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift rename to CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalEmulatorView.swift index 11683c9ce1..d75231783e 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift +++ b/CodeEditModules/Sources/CETerminal/TerminalEmulator/TerminalEmulatorView.swift @@ -6,6 +6,9 @@ // import SwiftUI +import CodeEditCore +import CodeEditSettings +import CodeEditUI import SwiftTerm /// # TerminalEmulatorView @@ -17,18 +20,20 @@ import SwiftTerm /// /// Caches the view in the ``TerminalCache`` to keep terminal state when the view is removed from the hierarchy. /// -struct TerminalEmulatorView: NSViewRepresentable { +public struct TerminalEmulatorView: NSViewRepresentable { enum TerminalMode { case shell(shellType: Shell?) case task(activeTask: CEActiveTask) } - @AppSettings(\.terminal) - var terminalSettings - @AppSettings(\.textEditing.font) - var fontSettings + @SettingsValue(TerminalSettings.self, \.self) + private var terminalSettings + @SettingsValue(TextEditingSettings.self, \.font) + private var fontSettings + @SettingsValue(ThemeSettings.self, \.matchAppearance) + private var themeMatchAppearance - @StateObject private var themeModel: ThemeModel = .shared + @EnvironmentObject private var activeTheme: ActiveTheme private var font: NSFont { if terminalSettings.useTextEditorFont { @@ -41,8 +46,8 @@ struct TerminalEmulatorView: NSViewRepresentable { private let terminalID: UUID private var url: URL - public var mode: TerminalMode - public var onTitleChange: (_ title: String) -> Void + var mode: TerminalMode + var onTitleChange: (_ title: String) -> Void /// Create an emulator view /// - Parameters: @@ -50,14 +55,19 @@ struct TerminalEmulatorView: NSViewRepresentable { /// - terminalID: The ID of the terminal. Used to restore state when switching away from the view. /// - shellType: The type of shell to use. Overrides any settings or auto-detection. /// - onTitleChange: A callback used when the terminal updates it's title. - init(url: URL, terminalID: UUID, shellType: Shell? = nil, onTitleChange: @escaping (_ title: String) -> Void) { + public init( + url: URL, + terminalID: UUID, + shellType: Shell? = nil, + onTitleChange: @escaping (_ title: String) -> Void + ) { self.url = url self.terminalID = terminalID self.mode = .shell(shellType: shellType) self.onTitleChange = onTitleChange } - init(url: URL, task: CEActiveTask) { + public init(url: URL, task: CEActiveTask) { terminalID = task.task.id self.url = url self.mode = .task(activeTask: task) @@ -66,67 +76,44 @@ struct TerminalEmulatorView: NSViewRepresentable { // MARK: - Settings - private func getTerminalCursor() -> CursorStyle { - let blink = terminalSettings.cursorBlink - switch terminalSettings.cursorStyle { - case .block: - return blink ? .blinkBlock : .steadyBlock - case .underline: - return blink ? .blinkUnderline : .steadyUnderline - case .bar: - return blink ? .blinkBar : .steadyBar - } - } - - /// Returns true if the `option` key should be treated as the `meta` key. - private var optionAsMeta: Bool { - terminalSettings.optionAsMeta + /// Whether the dark variant of the theme should be used: the theme is following system + /// appearance and the terminal is configured to always be dark. + private var useDarkTheme: Bool { + themeMatchAppearance && terminalSettings.darkAppearance } /// Returns the mapped array of `SwiftTerm.Color` objects of ANSI Colors private var colors: [SwiftTerm.Color] { - if let selectedTheme = Settings[\.theme].matchAppearance && Settings[\.terminal].darkAppearance - ? themeModel.selectedDarkTheme - : themeModel.selectedTheme, - let index = themeModel.themes.firstIndex(of: selectedTheme) { - return themeModel.themes[index].terminal.ansiColors.map { color in - SwiftTerm.Color(hex: color) - } + guard let selectedTheme = useDarkTheme ? activeTheme.dark : activeTheme.current else { + return [] + } + return selectedTheme.terminal.ansiColors.map { color in + SwiftTerm.Color(hex: color) } - return [] } /// Returns the `cursor` color of the selected theme private var cursorColor: NSColor { - if let selectedTheme = Settings[\.theme].matchAppearance && Settings[\.terminal].darkAppearance - ? themeModel.selectedDarkTheme - : themeModel.selectedTheme, - let index = themeModel.themes.firstIndex(of: selectedTheme) { - return NSColor(themeModel.themes[index].terminal.cursor.swiftColor) + guard let selectedTheme = useDarkTheme ? activeTheme.dark : activeTheme.current else { + return NSColor(.accentColor) } - return NSColor(.accentColor) + return NSColor(hex: selectedTheme.terminal.cursor.color) } /// Returns the `selection` color of the selected theme private var selectionColor: NSColor { - if let selectedTheme = Settings[\.theme].matchAppearance && Settings[\.terminal].darkAppearance - ? themeModel.selectedDarkTheme - : themeModel.selectedTheme, - let index = themeModel.themes.firstIndex(of: selectedTheme) { - return NSColor(themeModel.themes[index].terminal.selection.swiftColor) + guard let selectedTheme = useDarkTheme ? activeTheme.dark : activeTheme.current else { + return NSColor(.accentColor) } - return NSColor(.accentColor) + return NSColor(hex: selectedTheme.terminal.selection.color) } /// Returns the `text` color of the selected theme private var textColor: NSColor { - if let selectedTheme = Settings[\.theme].matchAppearance && Settings[\.terminal].darkAppearance - ? themeModel.selectedDarkTheme - : themeModel.selectedTheme, - let index = themeModel.themes.firstIndex(of: selectedTheme) { - return NSColor(themeModel.themes[index].terminal.text.swiftColor) + guard let selectedTheme = useDarkTheme ? activeTheme.dark : activeTheme.current else { + return NSColor(.primary) } - return NSColor(.primary) + return NSColor(hex: selectedTheme.terminal.text.color) } /// Returns the `background` color of the selected theme @@ -146,13 +133,14 @@ struct TerminalEmulatorView: NSViewRepresentable { // MARK: - NSViewRepresentable /// Inherited from NSViewRepresentable.makeNSView(context:). - func makeNSView(context: Context) -> CELocalShellTerminalView { + public func makeNSView(context: Context) -> CELocalShellTerminalView { let view: CELocalShellTerminalView switch mode { case .shell(let shellType): let isCached = TerminalCache.shared.getTerminalView(terminalID) != nil - view = TerminalCache.shared.getTerminalView(terminalID) ?? CELocalShellTerminalView(frame: .zero) + view = TerminalCache.shared.getTerminalView(terminalID) + ?? CELocalShellTerminalView(frame: .zero, settings: terminalSettings) if !isCached { view.startProcess(workspaceURL: url, shell: shellType) configureView(view) @@ -161,7 +149,7 @@ struct TerminalEmulatorView: NSViewRepresentable { if let output = activeTask.output { view = output } else { - let newView = CEActiveTaskTerminalView(activeTask: activeTask) + let newView = CEActiveTaskTerminalView(activeTask: activeTask, settings: terminalSettings) activeTask.output = newView view = newView } @@ -188,9 +176,8 @@ struct TerminalEmulatorView: NSViewRepresentable { terminal.selectedTextBackgroundColor = selectionColor terminal.nativeForegroundColor = textColor terminal.nativeBackgroundColor = terminalSettings.useThemeBackground ? backgroundColor : .clear - terminal.cursorStyleChanged(source: terminal.getTerminal(), newStyle: getTerminalCursor()) terminal.layer?.backgroundColor = CGColor.clear - terminal.optionAsMetaKey = optionAsMeta + terminal.apply(settings: terminalSettings) } private func scroller(_ terminal: CELocalShellTerminalView) -> NSScroller? { @@ -202,7 +189,8 @@ struct TerminalEmulatorView: NSViewRepresentable { return nil } - func updateNSView(_ view: CELocalShellTerminalView, context: Context) { + public func updateNSView(_ view: CELocalShellTerminalView, context: Context) { + view.font = font view.installColors(self.colors) view.caretColor = cursorColor.withAlphaComponent(0.5) view.caretTextColor = cursorColor.withAlphaComponent(0.5) @@ -210,14 +198,13 @@ struct TerminalEmulatorView: NSViewRepresentable { view.nativeForegroundColor = textColor view.nativeBackgroundColor = terminalSettings.useThemeBackground ? backgroundColor : .clear view.layer?.backgroundColor = .clear - view.optionAsMetaKey = optionAsMeta - view.cursorStyleChanged(source: view.getTerminal(), newStyle: getTerminalCursor()) + view.apply(settings: terminalSettings) view.appearance = colorAppearance view.getTerminal().softReset() view.feed(text: "") // send empty character to force colors to be redrawn } - func makeCoordinator() -> Coordinator { + public func makeCoordinator() -> Coordinator { Coordinator(terminalID: terminalID, mode: mode, onTitleChange: onTitleChange) } } diff --git a/CodeEditModules/Sources/CETerminal/TerminalSettings.swift b/CodeEditModules/Sources/CETerminal/TerminalSettings.swift new file mode 100644 index 0000000000..cfaeb0c5fc --- /dev/null +++ b/CodeEditModules/Sources/CETerminal/TerminalSettings.swift @@ -0,0 +1,114 @@ +// +// TerminalSettings.swift +// CodeEditModules/Settings +// +// Created by Nanashi Li on 2022/04/08. +// + +import AppKit +import CodeEditSettings +import Foundation + +/// The global settings for the terminal emulator +public struct TerminalSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "terminal" + + /// If true terminal will use editor theme. + @CodableDefault public var useEditorTheme = true + + /// If true terminal appearance will always be `dark`. Otherwise it adapts to the system setting. + @CodableDefault public var darkAppearance = false + + /// If true, the terminal uses the background color of the theme, otherwise it is clear + @CodableDefault public var useThemeBackground = true + + /// If true, the terminal treats the `Option` key as the `Meta` key + @CodableDefault public var optionAsMeta = false + + /// The selected shell to use. + @CodableDefault public var shell: Shell = .system + + /// The font to use in terminal. + @CodableDefault public var font: Font = .init() + + // The cursor style to use in terminal + @CodableDefault public var cursorStyle: CursorStyle = .block + + // Toggle for blinking cursor or not + @CodableDefault public var cursorBlink = false + + // Use font settings from Text Editing + @CodableDefault public var useTextEditorFont = true + + /// If `true`, use injection scripts for terminal features like automatic tab title. + @CodableDefault public var useShellIntegration = true + + /// If `true`, use a login shell. + @CodableDefault public var useLoginShell = true + + /// Default initializer + public init() {} + + /// The shell options. + /// - **bash**: uses the default bash shell + /// - **zsh**: uses the ZSH shell + /// - **system**: uses the system default shell (most likely ZSH) + public enum Shell: String, Codable, Hashable { + case bash + case zsh + case system + } + + public enum CursorStyle: String, Codable, Hashable { + case block + case underline + case bar + } + + public struct Font: Codable, Hashable { + /// The font size for the custom font + public var size: Double = 12 + + /// The name of the custom font + public var name: String = "SF Mono" + + /// The weight of the custom font + public var weight: NSFont.Weight = .medium + + /// Default initializer + public init() {} + + /// Explicit decoder init for setting default values when key is not present in `JSON` + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.size = try container.decodeIfPresent(Double.self, forKey: .size) ?? size + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? name + self.weight = try container.decodeIfPresent(NSFont.Weight.self, forKey: .weight) ?? weight + } + + /// Returns an NSFont representation of the current configuration. + /// + /// Returns the custom font, if enabled and able to be instantiated. + /// Otherwise returns a default system font monospaced. + public var current: NSFont { + let customFont = NSFont(name: name, size: size)?.withWeight(weight: weight) + return customFont ?? NSFont.monospacedSystemFont(ofSize: size, weight: .medium) + } + } +} + +// MARK: - Defaults + +public enum DefaultTerminalShell: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = TerminalSettings.Shell.system +} + +public enum DefaultTerminalCursorStyle: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = TerminalSettings.CursorStyle.block +} + +public enum DefaultTerminalFont: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = TerminalSettings.Font() +} diff --git a/CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/Array+SortURLs.swift similarity index 79% rename from CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/Array+SortURLs.swift index e3b2887b7c..80f1f67b6b 100644 --- a/CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/Array+SortURLs.swift @@ -1,6 +1,6 @@ // -// Array+FileSystem.FileItem.swift -// CodeEdit +// Array+SortURLs.swift +// CEWorkspaceFileManager // // Created by Matthijs Eikelenboom on 07/02/2023. // @@ -47,16 +47,3 @@ extension Array where Element == URL { return lhs < rhs } } - -extension Array where Element: Hashable { - - /// Checks the difference between two given items. - /// - Parameter other: Other element - /// - Returns: symmetricDifference - func difference(from other: [Element]) -> [Element] { - let thisSet = Set(self) - let otherSet = Set(other) - return Array(thisSet.symmetricDifference(otherSet)) - } - -} diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile+Recursion.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFile+Recursion.swift similarity index 98% rename from CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile+Recursion.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFile+Recursion.swift index 1647514a4e..f5722ec6f4 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile+Recursion.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFile+Recursion.swift @@ -1,11 +1,12 @@ // // CEWorkspaceFile+Recursion.swift -// CodeEdit +// CEWorkspaceFileManager // // Created by Matthijs Eikelenboom on 30/04/2023. // import Foundation +import CodeEditCore extension CEWorkspaceFile { /// Flattens the children of `self` recursively with depth. diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+DirectoryEvents.swift similarity index 63% rename from CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+DirectoryEvents.swift index 25715b51c2..2b5aa24795 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+DirectoryEvents.swift @@ -1,11 +1,12 @@ // // CEWorkspaceFileManager+DirectoryEvents.swift -// CodeEdit +// CEWorkspaceFileManager // // Created by Axel Martinez on 5/8/24. // import Foundation +import CodeEditCore /// This extension handles the file system events triggered by changes in the root folder. extension CEWorkspaceFileManager { @@ -45,87 +46,12 @@ extension CEWorkspaceFileManager { self.notifyObservers(updatedItems: files) } - if Settings.shared.preferences.sourceControl.general.sourceControlIsEnabled && - Settings.shared.preferences.sourceControl.general.refreshStatusLocally { - self.handleGitEvents(events: events) - } - } - } - - func handleGitEvents(events: [DirectoryEventStream.Event]) { - // Changes excluding .git folder - let notGitChanges = events.filter({ !$0.path.contains(".git/") }) - - // .git folder was changed - let gitFolderChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git" - }) - - // Change made to git index file, staged/unstaged files - let gitIndexChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git/index" - }) - - // Change made to git stash - let gitStashChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git/refs/stash" - }) - - // Changes made to git branches - let gitBranchChange = events.first(where: { - $0.path.contains("\(self.folderUrl.relativePath)/.git/refs/heads") - }) - - // Changes made to git HEAD - current branch changed - let gitHeadChange = events.first(where: { - $0.path.contains("\(self.folderUrl.relativePath)/.git/HEAD") - }) - - // Change made to remotes by looking at .git/config - let gitConfigChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git/config" - }) - - // If changes were made to project OR files were staged, refresh changes - if !notGitChanges.isEmpty || gitIndexChange != nil { - Task { - await self.sourceControlManager?.refreshAllChangedFiles() - } - } - - // If changes were stashed, refresh stashed entries - if gitStashChange != nil { - Task { - try await self.sourceControlManager?.refreshStashEntries() - } - } - - // If branches were added or removed, refresh branches - if gitBranchChange != nil { - Task { - await self.sourceControlManager?.refreshBranches() - } - } - - // If HEAD was changed, refresh the current branch - if gitHeadChange != nil { - Task { - await self.sourceControlManager?.refreshCurrentBranch() - } - } - - // If git config changed, refresh remotes - if gitConfigChange != nil { - Task { - try await self.sourceControlManager?.refreshRemotes() - } - } - - // If .git folder was added or removed, check if repository is valid - if gitFolderChange != nil { - Task { - try await self.sourceControlManager?.validate() - } + self.eventBus.publish( + WorkspaceFileEvent( + workspaceURL: self.folderUrl, + kind: .filesystemChanged(paths: events.map(\.path)) + ) + ) } } @@ -184,7 +110,7 @@ extension CEWorkspaceFileManager { /// Notify observers that an update occurred in the watched files. func notifyObservers(updatedItems: Set) { observers.allObjects.reversed().forEach { delegate in - guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { + guard let delegate = delegate as? WorkspaceFileObserver else { observers.remove(delegate) return } @@ -194,13 +120,13 @@ extension CEWorkspaceFileManager { /// Add an observer for file system events. /// - Parameter observer: The observer to add. - func addObserver(_ observer: CEWorkspaceFileManagerObserver) { + public func addObserver(_ observer: WorkspaceFileObserver) { observers.add(observer as AnyObject) } /// Remove an observer for file system events. /// - Parameter observer: The observer to remove. - func removeObserver(_ observer: CEWorkspaceFileManagerObserver) { + public func removeObserver(_ observer: WorkspaceFileObserver) { observers.remove(observer as AnyObject) } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+Error.swift similarity index 98% rename from CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+Error.swift index c56adc160b..f3385d4bd2 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+Error.swift @@ -1,6 +1,6 @@ // // CEWorkspaceFileManager+Error.swift -// CodeEdit +// CEWorkspaceFileManager // // Created by Khan Winter on 1/13/25. // diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+FileManagement.swift similarity index 85% rename from CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+FileManagement.swift index 82989fbffc..6b6d2cc16c 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager+FileManagement.swift @@ -1,12 +1,12 @@ // -// CEWorkspaceFileManager+FileSystem.swift -// CodeEdit +// CEWorkspaceFileManager+FileManagement.swift +// CEWorkspaceFileManager // // Created by Khan Winter on 9/30/23. // import Foundation -import AppKit +import CodeEditCore extension CEWorkspaceFileManager { /// This function allows creation of folders in the main directory or sub-folders @@ -15,7 +15,7 @@ extension CEWorkspaceFileManager { /// - file: The file to add the new folder to. /// - Returns: The ``CEWorkspaceFile`` representing the folder in the file manager's cache. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - func addFolder(folderName: String, toFile file: CEWorkspaceFile) throws -> CEWorkspaceFile { + public func addFolder(folderName: String, toFile file: CEWorkspaceFile) throws -> CEWorkspaceFile { // Check if folder, if it is create folder under self, else create on same level. var folderUrl = ( file.isFolder ? file.url.appending(path: folderName) @@ -59,7 +59,7 @@ extension CEWorkspaceFileManager { /// - Throws: Throws a `CocoaError.fileWriteUnknown` with the file url if creating the file fails, and calls /// ``rebuildFiles(fromItem:deep:)`` which throws other `FileManager` errors. /// - Returns: The ``CEWorkspaceFile`` representing the new file in the file manager's cache. - func addFile( + public func addFile( fileName: String, toFile file: CEWorkspaceFile, useExtension: String? = nil, @@ -159,46 +159,23 @@ extension CEWorkspaceFileManager { } /// This function deletes the item or folder from the current project by erasing immediately. - /// - Parameters: - /// - file: The file to delete - /// - confirmDelete: True to present an alert to confirm the delete. + /// - Parameter file: The file to delete + /// - Note: Presenting a confirmation is the caller's responsibility. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja., Paul Ebose *Moved from 7c27b1e* - public func delete(file: CEWorkspaceFile, confirmDelete: Bool = true) throws { + public func delete(file: CEWorkspaceFile) throws { // This function also has to account for how the // - file system can change outside of the editor - let fileName = file.name - - let deleteConfirmation = NSAlert() - deleteConfirmation.messageText = "Do you want to delete “\(fileName)”?" - deleteConfirmation.informativeText = "This item will be deleted immediately. You can't undo this action." - deleteConfirmation.alertStyle = .critical - deleteConfirmation.addButton(withTitle: "Delete") - deleteConfirmation.buttons.last?.hasDestructiveAction = true - deleteConfirmation.addButton(withTitle: "Cancel") - if !confirmDelete || deleteConfirmation.runModal() == .alertFirstButtonReturn { // "Delete" button - if fileManager.fileExists(atPath: file.url.path) { - try deleteFile(at: file.url) - } + if fileManager.fileExists(atPath: file.url.path) { + try deleteFile(at: file.url) } } /// This function deletes multiple files or folders from the current project by erasing immediately. - /// - Parameters: - /// - files: The files to delete - /// - confirmDelete: True to present an alert to confirm the delete. - public func batchDelete(files: Set, confirmDelete: Bool = true) throws { - let deleteConfirmation = NSAlert() - deleteConfirmation.messageText = "Are you sure you want to delete the \(files.count) selected items?" - // swiftlint:disable:next line_length - deleteConfirmation.informativeText = "\(files.count) items will be deleted immediately. You cannot undo this action." - deleteConfirmation.alertStyle = .critical - deleteConfirmation.addButton(withTitle: "Delete") - deleteConfirmation.buttons.last?.hasDestructiveAction = true - deleteConfirmation.addButton(withTitle: "Cancel") - if !confirmDelete || deleteConfirmation.runModal() == .alertFirstButtonReturn { - for file in files where fileManager.fileExists(atPath: file.url.path) { - try deleteFile(at: file.url) - } + /// - Parameter files: The files to delete + /// - Note: Presenting a confirmation is the caller's responsibility. + public func batchDelete(files: Set) throws { + for file in files where fileManager.fileExists(atPath: file.url.path) { + try deleteFile(at: file.url) } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager.swift similarity index 76% rename from CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager.swift index 2e0f824d11..c3a84adced 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/CEWorkspaceFileManager.swift @@ -1,18 +1,18 @@ // -// FileSystemClient.swift -// CodeEdit +// CEWorkspaceFileManager.swift +// CEWorkspaceFileManager // // Created by Matthijs Eikelenboom on 04/02/2023. // import Combine +import CodeEditCore import Foundation -import AppKit import OSLog -protocol CEWorkspaceFileManagerObserver: AnyObject { - func fileManagerUpdated(updatedItems: Set) -} +/// The observer protocol moved to `CodeEditCore` as ``WorkspaceFileObserver`` so +/// feature packages can observe without depending on this service target. +public typealias CEWorkspaceFileManagerObserver = WorkspaceFileObserver /// This class is used to load, modify, and listen to files on a user's machine. /// @@ -38,49 +38,83 @@ protocol CEWorkspaceFileManagerObserver: AnyObject { /// files under the ``CEWorkspaceFileManager/folderUrl`` url. Those can be passed on to listeners that conform to the /// ``CEWorkspaceFileManagerObserver`` protocol. Use the ``CEWorkspaceFileManager/addObserver(_:)`` /// and ``CEWorkspaceFileManager/removeObserver(_:)`` to add or remove observers. Observers are kept as weak references. -final class CEWorkspaceFileManager { +/// `@unchecked Sendable`: the mutable cache (`flattenedFileItems`, `childrenMap`, `observers`) is +/// main-thread-confined — filesystem events hop to the main queue before mutating, and all consumers +/// (UI, use cases) call in on the main thread. The reference is shared across threads (the FSEvents +/// callback thread invokes `fileSystemEventReceived`), which is why the annotation is required. +public final class CEWorkspaceFileManager: @unchecked Sendable { let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CEWorkspaceFileManager") - private(set) var fileManager: FileManager + public private(set) var fileManager: FileManager private(set) var ignoredFilesAndFolders: Set - var flattenedFileItems: [String: CEWorkspaceFile] + public internal(set) var flattenedFileItems: [String: CEWorkspaceFile] /// Maps all directories to it's children's paths. var childrenMap: [String: [String]] = [:] var fsEventStream: DirectoryEventStream? var observers: NSHashTable = .weakObjects() - let folderUrl: URL - let workspaceItem: CEWorkspaceFile - weak var sourceControlManager: SourceControlManager? + public let folderUrl: URL + public let workspaceItem: CEWorkspaceFile + let eventBus: EventBus + private var eventCancellables: Set = [] /// Create a file manager object with a root and a set of files to ignore. /// - Parameters: /// - folderUrl: The folder to use as the root of the file manager. /// - ignoredFilesAndFolders: A set of files to ignore. These should not be paths, but rather file names /// like `.DS_Store` - init( + public init( folderUrl: URL, ignoredFilesAndFolders: Set, fileManager: FileManager = FileManager.default, - sourceControlManager: SourceControlManager? + eventBus: EventBus ) { self.folderUrl = folderUrl self.ignoredFilesAndFolders = ignoredFilesAndFolders self.workspaceItem = CEWorkspaceFile(url: folderUrl) self.flattenedFileItems = [workspaceItem.id: workspaceItem] - self.sourceControlManager = sourceControlManager self.fileManager = fileManager + self.eventBus = eventBus + + subscribeToGitStatusEvents() self.loadChildrenForFile(self.workspaceItem) fsEventStream = DirectoryEventStream(directory: self.folderUrl.path) { [weak self] events in self?.fileSystemEventReceived(events: events) } + } + + /// Applies git statuses published by source control onto the cached files. + private func subscribeToGitStatusEvents() { + eventBus.subscribe(GitStatusChangedEvent.self) + .filter { [weak self] in $0.workspaceURL == self?.folderUrl } + .receive(on: RunLoop.main) + .sink { [weak self] event in + self?.applyGitStatuses(event.changed) + } + .store(in: &eventCancellables) + } - Task { - try await self.sourceControlManager?.validate() + /// Applies the given fileKey → status map onto cached files, clears any cached + /// file not present in the map, and notifies observers of the union. + private func applyGitStatuses(_ changed: [String: GitStatus]) { + var updatedStatusFor: Set = [] + for (key, status) in changed { + guard let file = getFile(key) else { continue } + if file.gitStatus != status { + file.gitStatus = status + } + updatedStatusFor.insert(file) + } + for (_, file) in flattenedFileItems + where !updatedStatusFor.contains(file) && file.gitStatus != nil { + file.gitStatus = nil + updatedStatusFor.insert(file) } + guard !updatedStatusFor.isEmpty else { return } + notifyObservers(updatedItems: updatedStatusFor) } // MARK: - Public API @@ -92,7 +126,7 @@ final class CEWorkspaceFileManager { /// - createIfNotFound: Set to true if the function should index any intermediate directories to find the file, /// as well as index the file if it is not already. /// - Returns: The file item corresponding to the file - func getFile( + public func getFile( _ path: String, createIfNotFound: Bool = false ) -> CEWorkspaceFile? { @@ -140,7 +174,7 @@ final class CEWorkspaceFileManager { /// ``CEWorkspaceFileManager/getFile(_:createIfNotFound:)`` to force a file to be loaded. /// - Parameter file: The file to find children for. /// - Returns: An array of children for the file, or `nil` if the file was not a directory. - func childrenOfFile(_ file: CEWorkspaceFile) -> [CEWorkspaceFile]? { + public func childrenOfFile(_ file: CEWorkspaceFile) -> [CEWorkspaceFile]? { if file.isFolder { if childrenMap[file.id] == nil { // Load the children @@ -170,9 +204,7 @@ final class CEWorkspaceFileManager { addedChildrenUrls.append(newFileItem.id) } childrenMap[file.id] = addedChildrenUrls - Task { - await sourceControlManager?.refreshAllChangedFiles() - } + eventBus.publish(WorkspaceFileEvent(workspaceURL: folderUrl, kind: .childrenIndexed)) } /// Creates an ordered array of all files and directories at the given file object. @@ -217,7 +249,7 @@ final class CEWorkspaceFileManager { /// Run when the owner of the ``CEWorkspaceFileManager`` doesn't need it anymore. /// This de-inits most functions in the ``CEWorkspaceFileManager``, so that in case it isn't de-init'd it does not /// use up significant amounts of RAM, and clears any file system event watchers. - func cleanUp() { + public func cleanUp() { fsEventStream?.cancel() flattenedFileItems = [workspaceItem.id: workspaceItem] } @@ -227,3 +259,5 @@ final class CEWorkspaceFileManager { observers.removeAllObjects() } } + +extension CEWorkspaceFileManager: WorkspaceFileProviding {} diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEditModules/Sources/CEWorkspaceFileManager/DirectoryEventStream.swift similarity index 99% rename from CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift rename to CodeEditModules/Sources/CEWorkspaceFileManager/DirectoryEventStream.swift index bfab19f41e..1e1b722938 100644 --- a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift +++ b/CodeEditModules/Sources/CEWorkspaceFileManager/DirectoryEventStream.swift @@ -1,6 +1,6 @@ // // DirectoryEventStream.swift -// CodeEdit +// CEWorkspaceFileManager // // Created by Khan Winter on 6/26/23. // diff --git a/CodeEdit/Utils/Extensions/Collection/Collection+subscript_safe.swift b/CodeEditModules/Sources/CodeEditCore/Collection+subscript_safe.swift similarity index 84% rename from CodeEdit/Utils/Extensions/Collection/Collection+subscript_safe.swift rename to CodeEditModules/Sources/CodeEditCore/Collection+subscript_safe.swift index a0a2d080e7..8830c99996 100644 --- a/CodeEdit/Utils/Extensions/Collection/Collection+subscript_safe.swift +++ b/CodeEditModules/Sources/CodeEditCore/Collection+subscript_safe.swift @@ -9,7 +9,7 @@ import Foundation extension Collection { /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript (safe index: Index) -> Element? { + public subscript (safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } } diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Commands/Command.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Commands/Command.swift new file mode 100644 index 0000000000..91dadd7a1d --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Commands/Command.swift @@ -0,0 +1,34 @@ +// +// Command.swift +// CodeEditDomain +// +// Created by Matthijs Eikelenboom on 12/04/26. +// + +import Foundation + +/// Command struct uses as a wrapper for command. Used by command palette to call selected commands. +public struct Command: Identifiable, Hashable { + + public static func == (lhs: Command, rhs: Command) -> Bool { + return lhs.id == rhs.id + } + + public static func < (lhs: Command, rhs: Command) -> Bool { + return false + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let title: String + public let closureWrapper: () -> Void + + public init(id: String, title: String, closureWrapper: @escaping () -> Void) { + self.id = id + self.title = title + self.closureWrapper = closureWrapper + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Editor/EditorCursorPosition.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/EditorCursorPosition.swift new file mode 100644 index 0000000000..d5fb81b163 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/EditorCursorPosition.swift @@ -0,0 +1,25 @@ +// +// EditorCursorPosition.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation + +/// The subset of an editor cursor/selection that the status bar renders, mirrored +/// as a plain value type so `CodeEditCore` need not depend on `CodeEditSourceEditor`. +public struct EditorCursorPosition: Sendable, Equatable { + /// 1-indexed line at the start of the selection (from `CursorPosition.start.line`). + public let line: Int + /// 1-indexed column at the start of the selection (from `CursorPosition.start.column`). + public let column: Int + /// The selection range in the document. + public let range: NSRange + + public init(line: Int, column: Int, range: NSRange) { + self.line = line + self.column = column + self.range = range + } +} diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorItemID.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/EditorItemID.swift similarity index 81% rename from CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorItemID.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Editor/EditorItemID.swift index 27cff36941..2433ffc44a 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorItemID.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/EditorItemID.swift @@ -1,6 +1,6 @@ // -// EditorTabID.swift -// +// EditorItemID.swift +// // // Created by Pavel Kasila on 30.04.22. // @@ -8,8 +8,8 @@ import Foundation /// Enum to represent item's ID to tab bar -enum EditorTabID: Codable, Identifiable, Hashable { - var id: String { +public enum EditorTabID: Codable, Identifiable, Hashable { + public var id: String { switch self { case .codeEditor(let path): return "codeEditor_\(path)" diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Editor/FileEditorOverrideValues.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/FileEditorOverrideValues.swift new file mode 100644 index 0000000000..f667633045 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/FileEditorOverrideValues.swift @@ -0,0 +1,31 @@ +// +// FileEditorOverrideValues.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation + +/// Per-file editor setting overrides, as plain values so `CodeEditCore` need not +/// name `CodeFileDocument` (which depends on Core) or `CodeLanguage` (heavy dep). +/// A `nil` field means "no override — use the global setting"; the language +/// override is carried as `CodeLanguage.id.rawValue`. +public struct FileEditorOverrideValues: Sendable, Equatable { + public var indentOption: IndentOption? + public var defaultTabWidth: Int? + public var wrapLines: Bool? + public var languageId: String? + + public init( + indentOption: IndentOption? = nil, + defaultTabWidth: Int? = nil, + wrapLines: Bool? = nil, + languageId: String? = nil + ) { + self.indentOption = indentOption + self.defaultTabWidth = defaultTabWidth + self.wrapLines = wrapLines + self.languageId = languageId + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Editor/IndentOption.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/IndentOption.swift new file mode 100644 index 0000000000..d42f7b5a2f --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Editor/IndentOption.swift @@ -0,0 +1,27 @@ +// +// IndentOption.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +/// The behavior of a `tab` keypress. If `.tab`, inserts a tab character; if `.spaces`, +/// inserts `spaceCount` spaces instead. +/// +/// A pure value type shared by the Settings feature and `CodeFileDocument`. +public struct IndentOption: Codable, Hashable, Sendable { + public var indentType: IndentType + // Kept even when `indentType` is `.tab` to retain the user's + // settings when changing `indentType`. + public var spaceCount: Int + + public init(indentType: IndentType, spaceCount: Int = 4) { + self.indentType = indentType + self.spaceCount = spaceCount + } + + public enum IndentType: String, Codable, Sendable { + case tab + case spaces + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/FindReplace/FindReplaceQuery.swift b/CodeEditModules/Sources/CodeEditCore/Domain/FindReplace/FindReplaceQuery.swift new file mode 100644 index 0000000000..f5ae4a4126 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/FindReplace/FindReplaceQuery.swift @@ -0,0 +1,18 @@ +// +// FindReplaceQuery.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation + +/// Shared find/replace query text, editable by both the Search and Editor features without +/// either depending on the other. Owned by Search's `SearchState` (workspace-scoped); Editor +/// mirrors it into each open file's find panel. +public final class FindReplaceQuery: ObservableObject { + @Published public var searchQuery: String = "" + @Published public var replaceText: String = "" + + public init() {} +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/Collection+FuzzyMatches.swift b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/Collection+FuzzyMatches.swift new file mode 100644 index 0000000000..a5b16319a4 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/Collection+FuzzyMatches.swift @@ -0,0 +1,42 @@ +// +// Collection+FuzzyMatches.swift +// CodeEdit +// +// Created by Tommy Ludwig on 03.02.24. +// + +import Foundation + +public extension Collection where Element: FuzzyMatchable & Sendable { + /// Concurrently fuzzy-matches a collection of elements conforming to FuzzyMatchable. + /// + /// - Parameter query: The query string to match against the elements. + /// + /// - Returns: Matching elements (weight > 0) paired with their match results, sorted by + /// descending weight. Elements with equal weight keep their input order. + /// + /// - Note: Because this is an extension on Collection and not only array, + /// you can also use this on sets. + func fuzzyMatches(query: String) async -> [(result: FuzzyMatchResult, item: Element)] { + let items = Array(self) + + let matches = await withTaskGroup(of: (Int, FuzzyMatchResult).self) { group in + for (index, item) in items.enumerated() { + group.addTask { + (index, item.fuzzyMatch(query: query)) + } + } + + var results = [FuzzyMatchResult?](repeating: nil, count: items.count) + for await (index, result) in group { + results[index] = result + } + return results + } + + return zip(matches, items) + .compactMap { match, item in match.map { (result: $0, item: item) } } + .filter { $0.result.weight > 0 } + .sorted { $0.result.weight > $1.result.weight } + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/FuzzyMatchModels.swift b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/FuzzyMatchModels.swift new file mode 100644 index 0000000000..5c2bce6aac --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/FuzzyMatchModels.swift @@ -0,0 +1,47 @@ +// +// FuzzyMatchModels.swift +// CodeEdit +// +// Created by Tommy Ludwig on 03.02.24. +// + +import Foundation + +/// A single character in a fuzzy match string, storing both original and normalised forms. +public struct FuzzyMatchCharacter { + /// The original character content. + public let content: String + /// The case- and accent-insensitive form of ``content``. + public let normalisedContent: String + + /// Creates a ``FuzzyMatchCharacter`` with the given original and normalised content. + public init(content: String, normalisedContent: String) { + self.content = content + self.normalisedContent = normalisedContent + } +} + +/// A sequence of ``FuzzyMatchCharacter`` values representing a string prepared for fuzzy matching. +public struct FuzzyMatchString { + /// The individual characters that make up this string. + public var characters: [FuzzyMatchCharacter] + + /// Creates a ``FuzzyMatchString`` from an array of ``FuzzyMatchCharacter`` values. + public init(characters: [FuzzyMatchCharacter]) { + self.characters = characters + } +} + +/// The result of a fuzzy match operation, containing a relevance weight and the matched ranges. +public struct FuzzyMatchResult: Sendable { + /// A score indicating how closely the input matched; higher values mean a better match. + public let weight: Int + /// The ranges within the original string that were matched. + public let matchedParts: [NSRange] + + /// Creates a ``FuzzyMatchResult`` with the given weight and matched ranges. + public init(weight: Int, matchedParts: [NSRange]) { + self.weight = weight + self.matchedParts = matchedParts + } +} diff --git a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchable.swift b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/FuzzyMatchable.swift similarity index 59% rename from CodeEdit/Features/Search/FuzzySearch/FuzzySearchable.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/FuzzyMatchable.swift index a961c2ad88..7233d7776e 100644 --- a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchable.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/FuzzyMatchable.swift @@ -1,5 +1,5 @@ // -// FuzzySearchable.swift +// FuzzyMatchable.swift // CodeEdit // // Created by Tommy Ludwig on 03.02.24. @@ -8,21 +8,24 @@ import Foundation /// A protocol defining the requirements for an object that can be searched using fuzzy matching. -protocol FuzzySearchable { +public protocol FuzzyMatchable { + /// The string content that fuzzy matches are made against. var searchableString: String { get } - /// Performs a fuzzy search on the conforming object's searchable string. + /// Performs a fuzzy match against the conforming object's searchable string. /// /// - Parameters: /// - query: The query string to match against the searchable content. /// - characters: The set of characters used for fuzzy matching. /// - /// - Returns: A FuzzySearchMatchResult indicating the result of the fuzzy search. - func fuzzyMatch(query: String, characters: FuzzySearchString) -> FuzzySearchMatchResult + /// - Returns: A FuzzyMatchResult indicating the result of the fuzzy match. + func fuzzyMatch(query: String, characters: FuzzyMatchString) -> FuzzyMatchResult } -extension FuzzySearchable { - func fuzzyMatch(query: String, characters: FuzzySearchString) -> FuzzySearchMatchResult { +public extension FuzzyMatchable { + /// Default implementation scoring consecutive character matches; returns a zero-weight result + /// when the query is not fully contained in the searchable string. + func fuzzyMatch(query: String, characters: FuzzyMatchString) -> FuzzyMatchResult { let compareString = characters.characters let searchString = query.lowercased() @@ -57,26 +60,26 @@ extension FuzzySearchable { if searchString.count == matchedParts.reduce(0, { partialResult, range in range.length + partialResult }) { - return FuzzySearchMatchResult(weight: totalScore, matchedParts: matchedParts) + return FuzzyMatchResult(weight: totalScore, matchedParts: matchedParts) } else { - return FuzzySearchMatchResult(weight: 0, matchedParts: []) + return FuzzyMatchResult(weight: 0, matchedParts: []) } } /// Normalises the searchable string of the conforming object by converting its characters to ASCII representation. - /// The resulting FuzzySearchString contains both the original and normalised content of each character. + /// The resulting FuzzyMatchString contains both the original and normalised content of each character. /// - /// - Returns: A FuzzySearchString - func normaliseString() -> FuzzySearchString { - return FuzzySearchString(characters: searchableString.normalise()) + /// - Returns: A FuzzyMatchString + func normaliseString() -> FuzzyMatchString { + return FuzzyMatchString(characters: searchableString.normalise()) } - /// Performs a fuzzy search on the normalised content of the conforming object's searchable string. + /// Performs a fuzzy match against the normalised content of the conforming object's searchable string. /// /// - Parameter query: The query string to match against the normalised searchable content. /// - /// - Returns: A FuzzySearchMatchResult indicating the result of the fuzzy search. - func fuzzyMatch(query: String) -> FuzzySearchMatchResult { + /// - Returns: A FuzzyMatchResult indicating the result of the fuzzy match. + func fuzzyMatch(query: String) -> FuzzyMatchResult { let characters = normaliseString() return fuzzyMatch(query: query, characters: characters) diff --git a/CodeEdit/Features/Search/FuzzySearch/String+LengthOfMatchingPrefix.swift b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/String+LengthOfMatchingPrefix.swift similarity index 79% rename from CodeEdit/Features/Search/FuzzySearch/String+LengthOfMatchingPrefix.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/String+LengthOfMatchingPrefix.swift index 4533ca8415..04f1c1abce 100644 --- a/CodeEdit/Features/Search/FuzzySearch/String+LengthOfMatchingPrefix.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/String+LengthOfMatchingPrefix.swift @@ -11,11 +11,11 @@ extension String { /// Returns the length of the matching prefix content or normalised content at the specified index. /// /// - Parameters: - /// - prefix: The FuzzySearchCharacter whose content or normalised content to check for a prefix match. + /// - prefix: The FuzzyMatchCharacter whose content or normalised content to check for a prefix match. /// - index: The index from which to start searching for the prefix. /// /// - Returns: The length of the matching prefix, or nil if no match is found. - func lengthOfMatchingPrefix(prefix: FuzzySearchCharacter, startingAt index: Int) -> Int? { + func lengthOfMatchingPrefix(prefix: FuzzyMatchCharacter, startingAt index: Int) -> Int? { guard let stringIndex = self.index(self.startIndex, offsetBy: index, limitedBy: self.endIndex) else { return nil } diff --git a/CodeEdit/Features/Search/FuzzySearch/String+Normalise.swift b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/String+Normalise.swift similarity index 57% rename from CodeEdit/Features/Search/FuzzySearch/String+Normalise.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/String+Normalise.swift index 40e9ee13ae..b104a6fbeb 100644 --- a/CodeEdit/Features/Search/FuzzySearch/String+Normalise.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/FuzzyMatching/String+Normalise.swift @@ -7,21 +7,21 @@ import Foundation -extension String { +public extension String { /// Normalises the characters of the string by converting them to ASCII representation. /// Each character is transformed into its ASCII equivalent, and the resulting array - /// of FuzzySearchCharacter objects contains both the original and normalised content. + /// of FuzzyMatchCharacter objects contains both the original and normalised content. /// - /// - Returns: An array of FuzzySearchCharacter objects representing the original and + /// - Returns: An array of FuzzyMatchCharacter objects representing the original and /// normalised content of each character in the string. - func normalise() -> [FuzzySearchCharacter] { + func normalise() -> [FuzzyMatchCharacter] { return self.lowercased().map { char in guard let data = String(char).data(using: .ascii, allowLossyConversion: true), let normalisedCharacter = String(data: data, encoding: .ascii) else { - return FuzzySearchCharacter(content: String(char), normalisedContent: String(char)) + return FuzzyMatchCharacter(content: String(char), normalisedContent: String(char)) } - return FuzzySearchCharacter(content: String(char), normalisedContent: normalisedCharacter) + return FuzzyMatchCharacter(content: String(char), normalisedContent: normalisedCharacter) } } } diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitBranch.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitBranch.swift new file mode 100644 index 0000000000..a72e5ec9e0 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitBranch.swift @@ -0,0 +1,44 @@ +// +// GitBranch.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +public struct GitBranch: Hashable, Identifiable, Sendable { + public let name: String + public let longName: String + public let upstream: String? + public let ahead: Int + public let behind: Int + + public var id: String { + longName + } + + /// Is local branch + public var isLocal: Bool { + return longName.hasPrefix("refs/heads/") + } + + /// Is remote branch + public var isRemote: Bool { + return longName.hasPrefix("refs/remotes/") + } + + public init( + name: String, + longName: String, + upstream: String?, + ahead: Int, + behind: Int + ) { + self.name = name + self.longName = longName + self.upstream = upstream + self.ahead = ahead + self.behind = behind + } +} diff --git a/CodeEdit/Features/SourceControl/Models/GitChangedFile.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitChangedFile.swift similarity index 54% rename from CodeEdit/Features/SourceControl/Models/GitChangedFile.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Git/GitChangedFile.swift index 641b8d3370..5e06c07ed7 100644 --- a/CodeEdit/Features/SourceControl/Models/GitChangedFile.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitChangedFile.swift @@ -1,47 +1,58 @@ // -// ChangedFile.swift -// +// GitChangedFile.swift +// // // Created by Nanashi Li on 2022/05/20. // import Foundation -import SwiftUI /// Represents a single changed file in the working tree. -struct GitChangedFile: Identifiable, Hashable { - var id: String { fileURL.relativePath } +public struct GitChangedFile: Identifiable, Hashable, Sendable { + public var id: String { fileURL.relativePath } /// The status of the file. - let status: GitStatus + public let status: GitStatus /// The staged status of the file. A non-`none` value here and in ``status`` may indicate a file that was added /// but has since been changed and needs to be re-added before committing. - let stagedStatus: GitStatus + public let stagedStatus: GitStatus /// URL of the file - let fileURL: URL + public let fileURL: URL /// The original file name if ``status`` or ``stagedStatus`` is `renamed` or `copied` - let originalFilename: String? + public let originalFilename: String? /// Returns the user-facing status, if ``status`` is `none`, returns ``stagedStatus``. - func anyStatus() -> GitStatus { + public func anyStatus() -> GitStatus { if case .none = status { return stagedStatus } return status } - var isStaged: Bool { + public var isStaged: Bool { stagedStatus != .none } /// Use this string to find matching `CEWorkspaceFile`s in the workspace file manager. - var ceFileKey: String { + public var ceFileKey: String { fileURL.absoluteURL.path(percentEncoded: false) } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(fileURL) } + + public init( + status: GitStatus, + stagedStatus: GitStatus, + fileURL: URL, + originalFilename: String? + ) { + self.status = status + self.stagedStatus = stagedStatus + self.fileURL = fileURL + self.originalFilename = originalFilename + } } diff --git a/CodeEdit/Features/SourceControl/Models/GitCommit.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitCommit.swift similarity index 59% rename from CodeEdit/Features/SourceControl/Models/GitCommit.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Git/GitCommit.swift index da2b95063a..e4b44c9815 100644 --- a/CodeEdit/Features/SourceControl/Models/GitCommit.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitCommit.swift @@ -8,22 +8,22 @@ import Foundation.NSDate /// Model class to help map commit history log data -struct GitCommit: Equatable, Hashable, Identifiable { - var id = UUID() - let hash: String - let commitHash: String - let message: String - let author: String - let authorEmail: String - let committer: String - let committerEmail: String - let body: String - let refs: [String] - let tag: String - let remoteURL: URL? - let date: Date +public struct GitCommit: Equatable, Hashable, Identifiable, Sendable { + public var id: UUID + public let hash: String + public let commitHash: String + public let message: String + public let author: String + public let authorEmail: String + public let committer: String + public let committerEmail: String + public let body: String + public let refs: [String] + public let tag: String + public let remoteURL: URL? + public let date: Date - var commitBaseURL: URL? { + public var commitBaseURL: URL? { if let remoteURL { if remoteURL.absoluteString.contains("github") { return parsedRemoteUrl(domain: "https://github.com", remote: remoteURL) @@ -51,7 +51,7 @@ struct GitCommit: Equatable, Hashable, Identifiable { return formattedRemote.deletingPathExtension().appending(path: "commit") } - var remoteString: String { + public var remoteString: String { if let remoteURL { if remoteURL.absoluteString.contains("github") { return "GitHub" @@ -65,4 +65,34 @@ struct GitCommit: Equatable, Hashable, Identifiable { } return "Remote" } + + public init( + id: UUID = UUID(), + hash: String, + commitHash: String, + message: String, + author: String, + authorEmail: String, + committer: String, + committerEmail: String, + body: String, + refs: [String], + tag: String, + remoteURL: URL?, + date: Date + ) { + self.id = id + self.hash = hash + self.commitHash = commitHash + self.message = message + self.author = author + self.authorEmail = authorEmail + self.committer = committer + self.committerEmail = committerEmail + self.body = body + self.refs = refs + self.tag = tag + self.remoteURL = remoteURL + self.date = date + } } diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitRemote.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitRemote.swift new file mode 100644 index 0000000000..30a1abc42e --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitRemote.swift @@ -0,0 +1,27 @@ +// +// GitRemote.swift +// CodeEdit +// +// Created by Austin Condiff on 11/17/23. +// + +import Foundation + +public struct GitRemote: Hashable, Sendable { + public let name: String + public let pushLocation: String + public let fetchLocation: String + public var branches: [GitBranch] + + public init( + name: String, + pushLocation: String, + fetchLocation: String, + branches: [GitBranch] = [] + ) { + self.name = name + self.pushLocation = pushLocation + self.fetchLocation = fetchLocation + self.branches = branches + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitStashEntry.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitStashEntry.swift new file mode 100644 index 0000000000..52c832dbb3 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitStashEntry.swift @@ -0,0 +1,24 @@ +// +// GitStashEntry.swift +// CodeEdit +// +// Created by Austin Condiff on 11/20/23. +// + +import Foundation + +public struct GitStashEntry: Hashable, Sendable { + public let index: Int + public let message: String + public let date: Date + + public init( + index: Int, + message: String, + date: Date + ) { + self.index = index + self.message = message + self.date = date + } +} diff --git a/CodeEdit/Features/SourceControl/Models/GitStatus.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitStatus.swift similarity index 85% rename from CodeEdit/Features/SourceControl/Models/GitStatus.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Git/GitStatus.swift index a5904cae82..ec2c346668 100644 --- a/CodeEdit/Features/SourceControl/Models/GitStatus.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Git/GitStatus.swift @@ -1,13 +1,13 @@ // -// GitType.swift -// +// GitStatus.swift +// // // Created by Nanashi Li on 2022/05/20. // import Foundation -enum GitStatus: String, Codable { +public enum GitStatus: String, Codable, Sendable { case none = "." case modified = "M" case untracked = "?" @@ -18,7 +18,7 @@ enum GitStatus: String, Codable { case copied = "C" case unmerged = "U" - var description: String { + public var description: String { switch self { case .modified: return "M" case .untracked: return "U" diff --git a/CodeEdit/Features/LSP/Registry/Model/RegistryItem+Source.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Registry/RegistryItem+Source.swift similarity index 70% rename from CodeEdit/Features/LSP/Registry/Model/RegistryItem+Source.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Registry/RegistryItem+Source.swift index 5a1b3e8261..c463498898 100644 --- a/CodeEdit/Features/LSP/Registry/Model/RegistryItem+Source.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Registry/RegistryItem+Source.swift @@ -6,19 +6,31 @@ // extension RegistryItem { - struct Source: Codable { - let id: String - let asset: AssetContainer? - let build: BuildContainer? - let versionOverrides: [VersionOverride]? + public struct Source: Codable, Sendable { + public let id: String + public let asset: AssetContainer? + public let build: BuildContainer? + public let versionOverrides: [VersionOverride]? - enum AssetContainer: Codable { + public init( + id: String, + asset: AssetContainer?, + build: BuildContainer?, + versionOverrides: [VersionOverride]? + ) { + self.id = id + self.asset = asset + self.build = build + self.versionOverrides = versionOverrides + } + + public enum AssetContainer: Codable, Sendable { case single(Asset) case multiple([Asset]) case simpleFile(String) case none - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { if let container = try? decoder.singleValueContainer() { if let singleValue = try? container.decode(Asset.self) { self = .single(singleValue) @@ -37,7 +49,7 @@ extension RegistryItem { self = .none } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .single(let value): @@ -51,7 +63,7 @@ extension RegistryItem { } } - func getDarwinFileName() -> String? { + public func getDarwinFileName() -> String? { switch self { case .single(let asset): if asset.target.isDarwinTarget() { @@ -73,12 +85,12 @@ extension RegistryItem { } } - enum BuildContainer: Codable { + public enum BuildContainer: Codable, Sendable { case single(Build) case multiple([Build]) case none - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { if let container = try? decoder.singleValueContainer() { if let singleValue = try? container.decode(Build.self) { self = .single(singleValue) @@ -91,7 +103,7 @@ extension RegistryItem { self = .none } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .single(let value): @@ -103,7 +115,7 @@ extension RegistryItem { } } - func getUnixBuildCommand() -> String? { + public func getUnixBuildCommand() -> String? { switch self { case .single(let build): return build.run @@ -121,31 +133,53 @@ extension RegistryItem { } } - struct Build: Codable { - let target: Target? - let run: String - let env: [String: String]? - let bin: BinContainer? + public struct Build: Codable, Sendable { + public let target: Target? + public let run: String + public let env: [String: String]? + public let bin: BinContainer? + + public init( + target: Target?, + run: String, + env: [String: String]?, + bin: BinContainer? + ) { + self.target = target + self.run = run + self.env = env + self.bin = bin + } } - struct Asset: Codable { - let target: Target - let file: String? - let bin: BinContainer? + public struct Asset: Codable, Sendable { + public let target: Target + public let file: String? + public let bin: BinContainer? - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.target = try container.decode(Target.self, forKey: .target) self.file = try container.decodeIfPresent(String.self, forKey: .file) self.bin = try container.decodeIfPresent(BinContainer.self, forKey: .bin) } + + public init( + target: Target, + file: String?, + bin: BinContainer? + ) { + self.target = target + self.file = file + self.bin = bin + } } - enum Target: Codable { + public enum Target: Codable, Sendable { case single(String) case multiple([String]) - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let singleValue = try? container.decode(String.self) { self = .single(singleValue) @@ -162,7 +196,7 @@ extension RegistryItem { } } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .single(let value): @@ -172,7 +206,7 @@ extension RegistryItem { } } - func isDarwinTarget() -> Bool { + public func isDarwinTarget() -> Bool { switch self { case .single(let value): #if arch(arm64) @@ -194,11 +228,11 @@ extension RegistryItem { } } - enum BinContainer: Codable { + public enum BinContainer: Codable, Sendable { case single(String) case multiple([String: String]) - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let singleValue = try? container.decode(String.self) { self = .single(singleValue) @@ -215,7 +249,7 @@ extension RegistryItem { } } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .single(let value): @@ -226,10 +260,20 @@ extension RegistryItem { } } - struct VersionOverride: Codable { - let constraint: String - let id: String - let asset: AssetContainer? + public struct VersionOverride: Codable, Sendable { + public let constraint: String + public let id: String + public let asset: AssetContainer? + + public init( + constraint: String, + id: String, + asset: AssetContainer? + ) { + self.constraint = constraint + self.id = id + self.asset = asset + } } } } diff --git a/CodeEdit/Features/LSP/Registry/Model/RegistryItem.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Registry/RegistryItem.swift similarity index 50% rename from CodeEdit/Features/LSP/Registry/Model/RegistryItem.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Registry/RegistryItem.swift index 763c5b2e80..fe86678809 100644 --- a/CodeEdit/Features/LSP/Registry/Model/RegistryItem.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Registry/RegistryItem.swift @@ -8,17 +8,37 @@ import Foundation /// A `RegistryItem` represents an entry in the Registry that saves language servers, DAPs, linters and formatters. -struct RegistryItem: Codable { - let name: String - let description: String - let homepage: String - let licenses: [String] - let languages: [String] - let categories: [String] - let source: Source - let bin: [String: String]? +public struct RegistryItem: Codable, Sendable { + public let name: String + public let description: String + public let homepage: String + public let licenses: [String] + public let languages: [String] + public let categories: [String] + public let source: Source + public let bin: [String: String]? - var sanitizedName: String { + public init( + name: String, + description: String, + homepage: String, + licenses: [String], + languages: [String], + categories: [String], + source: Source, + bin: [String: String]? + ) { + self.name = name + self.description = description + self.homepage = homepage + self.licenses = licenses + self.languages = languages + self.categories = categories + self.source = source + self.bin = bin + } + + public var sanitizedName: String { name.replacingOccurrences(of: "-", with: " ") .replacingOccurrences(of: "_", with: " ") .split(separator: " ") @@ -33,43 +53,23 @@ struct RegistryItem: Codable { .joined(separator: " ") } - var sanitizedDescription: String { + public var sanitizedDescription: String { description.replacingOccurrences(of: "\n", with: " ") } - var homepageURL: URL? { + public var homepageURL: URL? { URL(string: homepage) } /// A pretty version of the homepage URL. /// Removes the schema (eg https) and leaves the path and domain. - var homepagePretty: String { + public var homepagePretty: String { guard let homepageURL else { return homepage } return (homepageURL.host(percentEncoded: false) ?? "") + homepageURL.path(percentEncoded: false) } - /// The method for installation, parsed from this item's ``source-swift.property`` parameter. - var installMethod: InstallationMethod? { - let sourceId = source.id - if sourceId.hasPrefix("pkg:cargo/") { - return PackageSourceParser.parseCargoPackage(self) - } else if sourceId.hasPrefix("pkg:npm/") { - return PackageSourceParser.parseNpmPackage(self) - } else if sourceId.hasPrefix("pkg:pypi/") { - return PackageSourceParser.parsePythonPackage(self) - } else if sourceId.hasPrefix("pkg:gem/") { - return PackageSourceParser.parseRubyGem(self) - } else if sourceId.hasPrefix("pkg:golang/") { - return PackageSourceParser.parseGolangPackage(self) - } else if sourceId.hasPrefix("pkg:github/") { - return PackageSourceParser.parseGithubPackage(self) - } else { - return nil - } - } - /// Serializes back to JSON format - func toDictionary() throws -> [String: Any] { + public func toDictionary() throws -> [String: Any] { let data = try JSONEncoder().encode(self) let jsonObject = try JSONSerialization.jsonObject(with: data) guard let dictionary = jsonObject as? [String: Any] else { @@ -78,7 +78,3 @@ struct RegistryItem: Codable { return dictionary } } - -extension RegistryItem: FuzzySearchable { - var searchableString: String { name } -} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Tasks/TaskNotificationModel.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Tasks/TaskNotificationModel.swift new file mode 100644 index 0000000000..d9f327474b --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Tasks/TaskNotificationModel.swift @@ -0,0 +1,25 @@ +// +// TaskNotificationModel.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import Foundation + +/// Represents a notifications or tasks, that are displayed in the activity viewer +public struct TaskNotificationModel: Equatable, Sendable { + public var id: String + public var title: String + public var message: String? + public var percentage: Double? + public var isLoading: Bool = false + + public init(id: String, title: String, message: String? = nil, percentage: Double? = nil, isLoading: Bool = false) { + self.id = id + self.title = title + self.message = message + self.percentage = percentage + self.isLoading = isLoading + } +} diff --git a/CodeEdit/Utils/Protocols/Loopable.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Theme/Loopable.swift similarity index 86% rename from CodeEdit/Utils/Protocols/Loopable.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Theme/Loopable.swift index 85a8af3e5b..4609d728db 100644 --- a/CodeEdit/Utils/Protocols/Loopable.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Theme/Loopable.swift @@ -9,7 +9,8 @@ import Foundation /// Loopable protocol implements a method that will return all child /// properties and their associated values of a `Type` -protocol Loopable { +public protocol Loopable { + /// Returns all child properties and their associated values of `self`, keyed by property name. func allProperties() throws -> [String: Any] } @@ -30,7 +31,7 @@ extension Loopable { /// // returns /// ["name": "Steve", "books": 4] /// ``` - func allProperties() throws -> [String: Any] { + public func allProperties() throws -> [String: Any] { var result: [String: Any] = [:] let mirror = Mirror(reflecting: self) diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Theme/Theme.swift similarity index 70% rename from CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/Theme/Theme.swift index 749d1c1afd..f15963e876 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Theme/Theme.swift @@ -5,67 +5,66 @@ // Created by Lukas Pistrol on 31.03.22. // -import SwiftUI -import CodeEditSourceEditor +import Foundation // swiftlint:disable file_length /// # Theme /// /// The model structure of themes for the editor & terminal emulator -struct Theme: Identifiable, Codable, Equatable, Hashable, Loopable { +public struct Theme: Identifiable, Codable, Equatable, Hashable, Loopable, Sendable { enum CodingKeys: String, CodingKey { case author, license, distributionURL, name, displayName, editor, terminal, version case appearance = "type" case metadataDescription = "description" } - static func == (lhs: Theme, rhs: Theme) -> Bool { + public static func == (lhs: Theme, rhs: Theme) -> Bool { lhs.id == rhs.id } /// The `id` of the theme - var id: String { self.name } + public var id: String { self.name } /// The `author` of the theme - var author: String + public var author: String /// The `license` of the theme - var license: String + public var license: String /// A short `description` of the theme - var metadataDescription: String + public var metadataDescription: String /// An URL for reference - var distributionURL: String + public var distributionURL: String /// If the theme is bundled with CodeEdit or not - var isBundled: Bool = false + public var isBundled: Bool = false /// The URL for the theme file - var fileURL: URL? + public var fileURL: URL? /// The `unique name` of the theme - var name: String + public var name: String /// The `display name` of the theme - var displayName: String + public var displayName: String /// The `version` of the theme - var version: String + public var version: String /// The ``ThemeType`` of the theme /// /// Appears as `"type"` in the `settings.json` - var appearance: ThemeType + public var appearance: ThemeType /// Editor colors of the theme - var editor: EditorColors + public var editor: EditorColors /// Terminal colors of the theme - var terminal: TerminalColors + public var terminal: TerminalColors - init( + public init( editor: EditorColors, terminal: TerminalColors, author: String, @@ -96,7 +95,7 @@ extension Theme { /// The type of the theme /// - **dark**: this is a theme for dark system appearance /// - **light**: this is a theme for light system appearance - enum ThemeType: String, Codable, Hashable { + public enum ThemeType: String, Codable, Hashable, Sendable { case dark case light } @@ -108,27 +107,27 @@ extension Theme { /// /// As of now it only includes the colors `hex` string and /// an accessor for a `SwiftUI` `Color`. - struct Attributes: Codable, Equatable, Hashable, Loopable { + public struct Attributes: Codable, Equatable, Hashable, Loopable, Sendable { /// The 24-bit hex string of the color (e.g. #123456) - var color: String - var bold: Bool - var italic: Bool + public var color: String + public var bold: Bool + public var italic: Bool - init(color: String, bold: Bool = false, italic: Bool = false) { + public init(color: String, bold: Bool = false, italic: Bool = false) { self.color = color self.bold = bold self.italic = italic } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.color = try container.decode(String.self, forKey: .color) self.bold = try container.decodeIfPresent(Bool.self, forKey: .bold) ?? false self.italic = try container.decodeIfPresent(Bool.self, forKey: .italic) ?? false } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(color, forKey: .color) @@ -146,90 +145,28 @@ extension Theme { case bold case italic } - - /// The `SwiftUI` of ``color`` - var swiftColor: Color { - get { - Color(hex: color) - } - set { - self.color = newValue.hexString - } - } - - /// The `NSColor` of ``color`` - var nsColor: NSColor { - get { - NSColor(hex: color) - } - set { - self.color = newValue.hexString - } - } } } extension Theme { /// The editor colors of the theme - struct EditorColors: Codable, Hashable, Loopable { - - var editorTheme: EditorTheme { - get { - .init( - text: .init(color: text.nsColor), - insertionPoint: insertionPoint.nsColor, - invisibles: .init(color: invisibles.nsColor), - background: background.nsColor, - lineHighlight: lineHighlight.nsColor, - selection: selection.nsColor, - keywords: .init(color: keywords.nsColor), - commands: .init(color: commands.nsColor), - types: .init(color: types.nsColor), - attributes: .init(color: attributes.nsColor), - variables: .init(color: variables.nsColor), - values: .init(color: values.nsColor), - numbers: .init(color: numbers.nsColor), - strings: .init(color: strings.nsColor), - characters: .init(color: characters.nsColor), - comments: .init(color: comments.nsColor) - ) - } - set { - self.text.nsColor = newValue.text.color - self.insertionPoint.nsColor = newValue.insertionPoint - self.invisibles.nsColor = newValue.invisibles.color - self.background.nsColor = newValue.background - self.lineHighlight.nsColor = newValue.lineHighlight - self.selection.nsColor = newValue.selection - self.keywords.nsColor = newValue.keywords.color - self.commands.nsColor = newValue.commands.color - self.types.nsColor = newValue.types.color - self.attributes.nsColor = newValue.attributes.color - self.variables.nsColor = newValue.variables.color - self.values.nsColor = newValue.values.color - self.numbers.nsColor = newValue.numbers.color - self.strings.nsColor = newValue.strings.color - self.characters.nsColor = newValue.characters.color - self.comments.nsColor = newValue.comments.color - } - } - - var text: Attributes - var insertionPoint: Attributes - var invisibles: Attributes - var background: Attributes - var lineHighlight: Attributes - var selection: Attributes - var keywords: Attributes - var commands: Attributes - var types: Attributes - var attributes: Attributes - var variables: Attributes - var values: Attributes - var numbers: Attributes - var strings: Attributes - var characters: Attributes - var comments: Attributes + public struct EditorColors: Codable, Hashable, Loopable, Sendable { + public var text: Attributes + public var insertionPoint: Attributes + public var invisibles: Attributes + public var background: Attributes + public var lineHighlight: Attributes + public var selection: Attributes + public var keywords: Attributes + public var commands: Attributes + public var types: Attributes + public var attributes: Attributes + public var variables: Attributes + public var values: Attributes + public var numbers: Attributes + public var strings: Attributes + public var characters: Attributes + public var comments: Attributes /// Allows to look up properties by their name /// @@ -239,7 +176,7 @@ extension Theme { /// // equal to calling /// editor.text /// ``` - subscript(key: String) -> Attributes { + public subscript(key: String) -> Attributes { get { switch key { case "text": return self.text @@ -284,7 +221,7 @@ extension Theme { } } - init( + public init( text: Attributes, insertionPoint: Attributes, invisibles: Attributes, @@ -324,30 +261,30 @@ extension Theme { extension Theme { /// The terminal emulator colors of the theme - struct TerminalColors: Codable, Hashable, Loopable { - var text: Attributes - var boldText: Attributes - var cursor: Attributes - var background: Attributes - var selection: Attributes - var black: Attributes - var red: Attributes - var green: Attributes - var yellow: Attributes - var blue: Attributes - var magenta: Attributes - var cyan: Attributes - var white: Attributes - var brightBlack: Attributes - var brightRed: Attributes - var brightGreen: Attributes - var brightYellow: Attributes - var brightBlue: Attributes - var brightMagenta: Attributes - var brightCyan: Attributes - var brightWhite: Attributes - - var ansiColors: [String] { + public struct TerminalColors: Codable, Hashable, Loopable, Sendable { + public var text: Attributes + public var boldText: Attributes + public var cursor: Attributes + public var background: Attributes + public var selection: Attributes + public var black: Attributes + public var red: Attributes + public var green: Attributes + public var yellow: Attributes + public var blue: Attributes + public var magenta: Attributes + public var cyan: Attributes + public var white: Attributes + public var brightBlack: Attributes + public var brightRed: Attributes + public var brightGreen: Attributes + public var brightYellow: Attributes + public var brightBlue: Attributes + public var brightMagenta: Attributes + public var brightCyan: Attributes + public var brightWhite: Attributes + + public var ansiColors: [String] { [ black.color, red.color, @@ -376,7 +313,7 @@ extension Theme { /// // equal to calling /// terminal.text /// ``` - subscript(key: String) -> Attributes { + public subscript(key: String) -> Attributes { get { switch key { case "text": return self.text @@ -431,7 +368,7 @@ extension Theme { } } - init( + public init( text: Attributes, boldText: Attributes, cursor: Attributes, diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Workspace/CEWorkspaceFile.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Workspace/CEWorkspaceFile.swift new file mode 100644 index 0000000000..4c8d66f991 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Workspace/CEWorkspaceFile.swift @@ -0,0 +1,130 @@ +// +// CEWorkspaceFile.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 07/02/2023. +// + +import Foundation +import UniformTypeIdentifiers + +/// A file, folder, or symlink in the workspace. This is the UI-free model; presentation, +/// AppKit intents, name-labeling, and editor-document coupling live in app-side extensions. +public final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable { + + /// The id of the ``CEWorkspaceFile``. + public var id: String + + /// Returns the file name (e.g.: `Package.swift`) + public var name: String { url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) } + + /// Returns the URL of the ``CEWorkspaceFile`` + public let url: URL + + /// Returns the resolved symlink url of this object. + public lazy var resolvedURL: URL = { + url.isSymbolicLink ? url.resolvingSymlinksInPath() : url + }() + + /// Returns a parent ``CEWorkspaceFile``. `nil` for the top-level item. + public weak var parent: CEWorkspaceFile? + + public var fileIdentifier = UUID().uuidString + + /// The Git status of the file. + public var gitStatus: GitStatus? + + /// Whether the file is staged for commit. + public var staged: Bool? + + /// True if the resource is a directory. + public lazy var isFolder: Bool = { + resolvedURL.isFolder + }() + + /// True if this directory has no contents. (Check ``isFolder`` first.) + public var isEmptyFolder: Bool { + (try? FileManager.default.contentsOfDirectory( + at: resolvedURL, + includingPropertiesForKeys: nil, + options: .skipsSubdirectoryDescendants + ).isEmpty) ?? true + } + + /// True if this is the workspace's root folder. + public var isRoot: Bool { parent == nil } + + /// True if the file exists on disk. + public var doesExist: Bool { FileManager.default.fileExists(atPath: self.url.path) } + + /// The file's UTType. + public var contentType: UTType? { url.contentType } + + public init( + id: String, + url: URL, + changeType: GitStatus? = nil, + staged: Bool? = false + ) { + self.id = id + self.url = url + self.gitStatus = changeType + self.staged = staged + } + + public convenience init( + url: URL, + changeType: GitStatus? = nil, + staged: Bool? = false + ) { + self.init(id: url.relativePath, url: url, changeType: changeType, staged: staged) + } + + enum CodingKeys: String, CodingKey { + case id, name, url, changeType, staged + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + url = try values.decode(URL.self, forKey: .url) + gitStatus = try values.decode(GitStatus.self, forKey: .changeType) + staged = try values.decode(Bool.self, forKey: .staged) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(url, forKey: .url) + try container.encode(gitStatus, forKey: .changeType) + try container.encode(staged, forKey: .staged) + } + + /// Returns the file name, optionally without its extension. + public func fileName(typeHidden: Bool = false) -> String { + typeHidden + ? url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + : name + } + + /// Parent directory for a file, or self for a folder. + public var nearestFolder: URL { + isFolder ? url : url.deletingLastPathComponent() + } + + // MARK: Comparable / Hashable + + public static func == (lhs: CEWorkspaceFile, rhs: CEWorkspaceFile) -> Bool { + lhs.id == rhs.id + } + + public static func < (lhs: CEWorkspaceFile, rhs: CEWorkspaceFile) -> Bool { + lhs.url.lastPathComponent < rhs.url.lastPathComponent + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(url) + hasher.combine(id) + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Domain/Workspace/WorkspaceFileProviding.swift b/CodeEditModules/Sources/CodeEditCore/Domain/Workspace/WorkspaceFileProviding.swift new file mode 100644 index 0000000000..e55434a606 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Domain/Workspace/WorkspaceFileProviding.swift @@ -0,0 +1,38 @@ +// +// WorkspaceFileProviding.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +import Foundation + +/// Read-mostly access to a workspace's file tree, plus change observation. +/// +/// The interface features program against; the implementation is the +/// `CEWorkspaceFileManager` service. Deliberately narrow — it carries only what +/// feature packages actually consume (path resolution, child listing, change +/// observation), not the mutating file-management API, which stays on the +/// concrete service for app-shell use. +public protocol WorkspaceFileProviding: AnyObject { + /// The root folder of the workspace. + var folderUrl: URL { get } + + /// Resolves a path to its file item, optionally indexing intermediate + /// directories to find it. + func getFile(_ path: String, createIfNotFound: Bool) -> CEWorkspaceFile? + + /// The cached children of a directory item, if loaded. + func childrenOfFile(_ file: CEWorkspaceFile) -> [CEWorkspaceFile]? + + /// Registers an observer for file-tree changes. Observers are held weakly. + func addObserver(_ observer: WorkspaceFileObserver) + + /// Removes a previously registered observer. + func removeObserver(_ observer: WorkspaceFileObserver) +} + +/// Receives file-tree change notifications from a ``WorkspaceFileProviding``. +public protocol WorkspaceFileObserver: AnyObject { + func fileManagerUpdated(updatedItems: Set) +} diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CETask.swift b/CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CETask.swift similarity index 69% rename from CodeEdit/Features/CEWorkspaceSettings/Models/CETask.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CETask.swift index 9f416e9a3a..f813a1bc92 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CETask.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CETask.swift @@ -1,6 +1,6 @@ // // CETask.swift -// CodeEdit +// CodeEditCore // // Created by Axel Martinez on 2/4/24. // @@ -8,15 +8,15 @@ import Foundation /// CodeEdit task that will be executed by the task manager. -class CETask: ObservableObject, Identifiable, Hashable, Codable { - @Published var id = UUID() - @Published var name: String = "" - @Published var target: String = "" - @Published var workingDirectory: String = "" - @Published var command: String = "" - @Published var environmentVariables: [EnvironmentVariable] = [] - - init( +public struct CETask: Identifiable, Hashable, Codable, Sendable { + public var id = UUID() + public var name: String + public var target: String + public var workingDirectory: String + public var command: String + public var environmentVariables: [EnvironmentVariable] + + public init( name: String = "", target: String = "", workingDirectory: String = "", @@ -30,17 +30,21 @@ class CETask: ObservableObject, Identifiable, Hashable, Codable { self.environmentVariables = environmentVariables } - init(target: String) { + public init(target: String) { + self.name = "" self.target = target + self.workingDirectory = "" + self.command = "" + self.environmentVariables = [] } - var isInvalid: Bool { + public var isInvalid: Bool { name.isEmpty || command.isEmpty } /// Ensures that the shell navigates to the correct folder, and then executes the specified command. - var fullCommand: String { + public var fullCommand: String { // Move into the specified folder if needed let changeDirectoryCommand = workingDirectory.isEmpty ? "" : "cd \(workingDirectory.escapedDirectory()) && " @@ -51,7 +55,7 @@ class CETask: ObservableObject, Identifiable, Hashable, Codable { /// Converts an array of `EnvironmentVariable` to a dictionary. /// /// - Returns: A dictionary with the environment variable keys and values. - var environmentVariablesDictionary: [String: String] { + public var environmentVariablesDictionary: [String: String] { return environmentVariables.reduce(into: [String: String]()) { result, environmentVariable in result[environmentVariable.key] = environmentVariable.value } @@ -65,20 +69,20 @@ class CETask: ObservableObject, Identifiable, Hashable, Codable { case environmentVariables } - struct EnvironmentVariable: Identifiable, Hashable { - var id = UUID() - var key: String = "" - var value: String = "" + public struct EnvironmentVariable: Identifiable, Hashable, Sendable { + public var id = UUID() + public var key: String = "" + public var value: String = "" - init() {} + public init() {} - init(key: String, value: String) { + public init(key: String, value: String) { self.key = key self.value = value } } - required init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) target = try container.decodeIfPresent(String.self, forKey: .target) ?? "" @@ -88,10 +92,12 @@ class CETask: ObservableObject, Identifiable, Hashable, Codable { // Decode environment variables from a dictionary-like structure if let envDict = try container.decodeIfPresent([String: String].self, forKey: .environmentVariables) { environmentVariables = envDict.map { EnvironmentVariable(key: $0.key, value: $0.value) } + } else { + environmentVariables = [] } } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if !name.isEmpty { try container.encode(name, forKey: .name) @@ -118,23 +124,3 @@ class CETask: ObservableObject, Identifiable, Hashable, Codable { } } } - -extension CETask { - static func == (lhs: CETask, rhs: CETask) -> Bool { - return lhs.id == rhs.id && - lhs.name == rhs.name && - lhs.target == rhs.target && - lhs.workingDirectory == rhs.workingDirectory && - lhs.command == rhs.command && - lhs.environmentVariables == rhs.environmentVariables - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(name) - hasher.combine(target) - hasher.combine(workingDirectory) - hasher.combine(command) - hasher.combine(environmentVariables) - } -} diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettingsData+ProjectSettings.swift b/CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CEWorkspaceSettingsData+ProjectSettings.swift similarity index 54% rename from CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettingsData+ProjectSettings.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CEWorkspaceSettingsData+ProjectSettings.swift index 110fcfcdf2..12225c8c07 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettingsData+ProjectSettings.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CEWorkspaceSettingsData+ProjectSettings.swift @@ -1,24 +1,24 @@ // -// ProjectCEWorkspaceSettings.swift -// CodeEdit +// CEWorkspaceSettingsData+ProjectSettings.swift +// CodeEditCore // // Created by Axel Martinez on 27/3/24. // -import SwiftUI +import Foundation -class ProjectSettings: ObservableObject, Codable { - var projectName: String = "" +public struct ProjectSettings: Codable, Sendable, Equatable { + public var projectName: String = "" - init() {} + public init() {} /// Explicit decoder init for setting default values when key is not present in `JSON` - required init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.projectName = try container.decodeIfPresent(String.self, forKey: .projectName) ?? "" } - func isEmpty() -> Bool { + public func isEmpty() -> Bool { projectName == "" } } diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettingsData.swift b/CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CEWorkspaceSettingsData.swift similarity index 63% rename from CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettingsData.swift rename to CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CEWorkspaceSettingsData.swift index 9fcbb2a1a3..b263e10150 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettingsData.swift +++ b/CodeEditModules/Sources/CodeEditCore/Domain/WorkspaceSettings/CEWorkspaceSettingsData.swift @@ -1,6 +1,6 @@ // // CEWorkspaceSettingsData.swift -// CodeEdit +// CodeEditCore // // Created by Tommy Ludwig on 01.07.24. // @@ -8,27 +8,30 @@ import Foundation /// The model of the workspace settings for `CodeEdit` that control the behavior of some functionality at the workspace -/// level like the workspace name or defining tasks. A `JSON` representation is persisted in the workspace's -/// `./codeedit/settings.json`. file -class CEWorkspaceSettingsData: ObservableObject, Codable { - @Published var project: ProjectSettings = .init() - @Published var tasks: [CETask] = [] +/// level like the workspace name or defining tasks. A `JSON` representation is persisted in the workspace's +/// `.codeedit/settings.json` file. +public struct CEWorkspaceSettingsData: Codable, Sendable, Equatable { + public var project: ProjectSettings + public var tasks: [CETask] - init() { } + public init(project: ProjectSettings = .init(), tasks: [CETask] = []) { + self.project = project + self.tasks = tasks + } enum CodingKeys: CodingKey { case project, tasks } /// Explicit decoder init for setting default values when key is not present in `JSON` - required init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.project = try container.decodeIfPresent(ProjectSettings.self, forKey: .project) ?? .init() self.tasks = try container.decodeIfPresent([CETask].self, forKey: .tasks) ?? [] } /// Encode the instance into the encoder - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if !project.isEmpty() { try container.encode(project, forKey: .project) @@ -38,7 +41,7 @@ class CEWorkspaceSettingsData: ObservableObject, Codable { } } - func isEmpty() -> Bool { + public func isEmpty() -> Bool { project.isEmpty() && tasks.isEmpty } } diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveCursorState.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveCursorState.swift new file mode 100644 index 0000000000..d391367659 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveCursorState.swift @@ -0,0 +1,32 @@ +// +// ActiveCursorState.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Combine + +/// Read-model for the currently active editor's cursor/selection. Lets the status +/// bar render cursor position without depending on the Editor feature's +/// `EditorManager` / `EditorInstance` or the `CodeEditSourceEditor` widget. +/// Workspace-scoped: one per window. +public protocol ActiveCursorState: AnyObject { + @MainActor var cursorPositions: [EditorCursorPosition] { get } + @MainActor var cursorPositionsPublisher: AnyPublisher<[EditorCursorPosition], Never> { get } + /// Number of lines contained by `range` in the active editor's live text view. + /// Returns 0 when there is no active editor or the lines cannot be resolved. + @MainActor + func linesInRange(_ range: NSRange) -> Int +} + +/// Default used when no cursor state is injected (tests, previews); reports no cursor. +public final class NoOpActiveCursorState: ActiveCursorState { + public init() {} + public var cursorPositions: [EditorCursorPosition] { [] } + public var cursorPositionsPublisher: AnyPublisher<[EditorCursorPosition], Never> { + Just([]).eraseToAnyPublisher() + } + public func linesInRange(_ range: NSRange) -> Int { 0 } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveEditorState.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveEditorState.swift new file mode 100644 index 0000000000..03a2029647 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveEditorState.swift @@ -0,0 +1,26 @@ +// +// ActiveEditorState.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Combine + +/// Read-model for the workspace's currently active file. Lets StatusBar / Inspector / +/// Navigator observe "which file is active" without depending on the Editor feature's +/// `EditorManager`/`Editor`/`EditorInstance`. Workspace-scoped: one per window. +public protocol ActiveEditorState: AnyObject { + @MainActor var selectedFile: CEWorkspaceFile? { get } + @MainActor var selectedFilePublisher: AnyPublisher { get } +} + +/// Default used when no editor state is injected (tests, previews); reports no active file. +public final class NoOpActiveEditorState: ActiveEditorState { + public init() {} + public var selectedFile: CEWorkspaceFile? { nil } + public var selectedFilePublisher: AnyPublisher { + Just(nil).eraseToAnyPublisher() + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveTheme.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveTheme.swift new file mode 100644 index 0000000000..98d6250a1f --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ActiveTheme.swift @@ -0,0 +1,42 @@ +// +// ActiveTheme.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/08/26. +// + +import Combine + +/// The themes currently in effect, published to whatever is rendering with them. +/// +/// **Holds only the themes in effect — keep it that way.** An `ObservableObject` invalidates every +/// observer on any published change, and the observers here are the code editor and the terminal. +/// Adding frequently-changing state would re-render both for changes they do not care about. +/// Theme *management* — the list, selection UI, add/edit state — stays in the app-side `ThemeModel`. +/// +/// Lives in `CodeEditCore` because `ObservableObject` is Combine, not SwiftUI: Core's charter forbids +/// only `SwiftUI`, `AppKit` and `Cocoa`. `FindReplaceQuery` is the existing precedent. +public final class ActiveTheme: ObservableObject { + + /// The theme in effect, or `nil` before any theme has loaded. + @Published public private(set) var current: Theme? + + /// The theme to use where a dark appearance is forced independently of `current` — the terminal + /// does this when its own `darkAppearance` setting is on. + @Published public private(set) var dark: Theme? + + public init() {} + + /// Assigns unconditionally, publishing on every call. + /// + /// **Do not reinstate an equality guard here.** One was tried and removed: ``Theme`` is + /// `Equatable` by *name* (`Theme.==` compares `id`, which is `name`), because + /// `themes.firstIndex(of:)` relies on that to find a theme to update in place. A `!=` guard + /// therefore reads "same theme" for an edited copy of the active theme and silently swallows + /// colour changes, leaving this holder on a stale struct forever. Writes are human-scale — a + /// theme switch or a colour edit — so the redundant publishes cost nothing worth guarding. + public func update(current: Theme?, dark: Theme?) { + self.current = current + self.dark = dark + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/ErrorNotifying.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ErrorNotifying.swift new file mode 100644 index 0000000000..5765d0b10e --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ErrorNotifying.swift @@ -0,0 +1,21 @@ +// +// ErrorNotifying.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 12/07/2026. +// + +import Foundation + +/// Posts a user-visible error notification. A one-method seam so packages can +/// surface errors without depending on the CENotifications feature package. +public protocol ErrorNotifying: AnyObject { + @MainActor + func postError(title: String, description: String) +} + +public final class NoOpErrorNotifier: ErrorNotifying { + public init() {} + @MainActor + public func postError(title: String, description: String) {} +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/CENotificationEvent.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/CENotificationEvent.swift new file mode 100644 index 0000000000..25e149a736 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/CENotificationEvent.swift @@ -0,0 +1,30 @@ +// +// CENotificationEvent.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 02/07/2026. +// + +import Foundation + +/// Describes a mutation of the in-app notification list. +/// +/// Published by `NotificationManager`; consumed by `NotificationPanelViewModel`. +/// +/// Carries only the notification id: the full `CENotification` model is UI-facing +/// (SwiftUI types and an action closure) and stays in the app target. Subscribers +/// resolve the model via `NotificationManager` when they need more than the id. +public struct CENotificationEvent: Event { + public enum Action: Sendable { + /// A notification was added to the list. + case added(id: UUID) + /// A notification was dismissed and removed from the list. + case dismissed(id: UUID) + } + + public let action: Action + + public init(_ action: Action) { + self.action = action + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/Event.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/Event.swift new file mode 100644 index 0000000000..18c34714d6 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/Event.swift @@ -0,0 +1,12 @@ +// +// Event.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 01.07.26. +// + +/// Marker protocol for all typed EventBus events. +/// +/// Conforming types are published via `EventBus` and received by subscribers +/// via `AnyCancellable`. All events must be `Sendable`. +public protocol Event: Sendable {} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/EventBus.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/EventBus.swift new file mode 100644 index 0000000000..f7bcab0fc3 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/EventBus.swift @@ -0,0 +1,54 @@ +// +// EventBus.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 01.07.26. +// + +import Combine +import Foundation + +/// Typed, Combine-backed publish/subscribe event bus. +/// +/// Each event type has its own `PassthroughSubject`. Publishing is synchronous +/// on the calling thread. Register as a Factory singleton via `Container.eventBus`. +/// +/// **Publishing:** +/// ```swift +/// eventBus.publish(WelcomeWindowRequestedEvent()) +/// ``` +/// +/// **Subscribing:** +/// ```swift +/// eventBus.subscribe(WelcomeWindowRequestedEvent.self) +/// .receive(on: RunLoop.main) +/// .sink { event in ... } +/// .store(in: &cancellables) +/// ``` +public final class EventBus: @unchecked Sendable { + private var subjects: [ObjectIdentifier: Any] = [:] + private let lock = NSLock() + + public init() {} + + /// Publish an event to all current subscribers. + public func publish(_ event: E) { + subject(for: E.self).send(event) + } + + /// Returns a publisher that emits events of the given type. + public func subscribe(_ type: E.Type) -> AnyPublisher { + subject(for: type).eraseToAnyPublisher() + } + + private func subject(for type: E.Type) -> PassthroughSubject { + lock.lock(); defer { lock.unlock() } + let key = ObjectIdentifier(type) + if let existing = subjects[key] as? PassthroughSubject { + return existing + } + let created = PassthroughSubject() + subjects[key] = created + return created + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/GitStatusChangedEvent.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/GitStatusChangedEvent.swift new file mode 100644 index 0000000000..8fbd0ebb11 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/GitStatusChangedEvent.swift @@ -0,0 +1,25 @@ +// +// GitStatusChangedEvent.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import Foundation + +/// A snapshot of the current git status for every changed file in a workspace, +/// published by source control and consumed by the workspace file manager, which +/// applies the statuses onto its cached files and clears any file not present here. +public struct GitStatusChangedEvent: Event { + /// Restricts delivery to the workspace at this URL. + public let workspaceURL: URL + + /// fileKey → status for every currently-changed file. The file manager clears + /// `gitStatus` on any cached file whose key is NOT present in this map. + public let changed: [String: GitStatus] + + public init(workspaceURL: URL, changed: [String: GitStatus]) { + self.workspaceURL = workspaceURL + self.changed = changed + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/TaskNotificationEvent.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/TaskNotificationEvent.swift new file mode 100644 index 0000000000..0333f1dc0e --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/TaskNotificationEvent.swift @@ -0,0 +1,44 @@ +// +// TaskNotificationEvent.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 02.07.26. +// + +import Foundation + +/// Describes a mutation of the task-notification list shown in the activity viewer. +/// +/// Published by any feature reporting long-running work (tasks, indexing, +/// package installs); consumed by `TaskNotificationHandler`. +public struct TaskNotificationEvent: Event { + public enum Action: Sendable { + /// Appends a new notification to the end of the list. + case create(TaskNotificationModel) + /// Inserts a new notification at the front of the list so it shows + /// immediately in the activity viewer. Reserve for important notifications. + case createWithPriority(TaskNotificationModel) + /// Updates an existing notification by id. Only non-`nil` fields are applied. + case update( + id: String, + title: String? = nil, + message: String? = nil, + percentage: Double? = nil, + isLoading: Bool? = nil + ) + /// Removes the notification with the given id. + case delete(id: String) + /// Removes the notification with the given id after `delay` seconds. + case deleteWithDelay(id: String, delay: TimeInterval) + } + + public let action: Action + + /// Restricts delivery to the workspace at this URL; `nil` reaches all workspaces. + public let workspaceURL: URL? + + public init(_ action: Action, workspace: URL? = nil) { + self.action = action + self.workspaceURL = workspace + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/WelcomeWindowRequestedEvent.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/WelcomeWindowRequestedEvent.swift new file mode 100644 index 0000000000..19e34fe8f7 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/WelcomeWindowRequestedEvent.swift @@ -0,0 +1,14 @@ +// +// WelcomeWindowRequestedEvent.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 01.07.26. +// + +/// Published by `WorkspaceWindowManager` when the last workspace closes +/// and the user preference is set to show the welcome window. +/// +/// Consumed by `AppDelegate`, which holds the SwiftUI `openWindow` environment action. +public struct WelcomeWindowRequestedEvent: Event { + public init() {} +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/WorkspaceFileEvent.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/WorkspaceFileEvent.swift new file mode 100644 index 0000000000..8d754920ed --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/Events/WorkspaceFileEvent.swift @@ -0,0 +1,31 @@ +// +// WorkspaceFileEvent.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import Foundation + +/// A raw filesystem change under a workspace root, published by the workspace +/// file manager and consumed by features that react to file changes (e.g. source +/// control). The file manager is deliberately git-agnostic: it emits raw paths and +/// lets subscribers interpret them. +public struct WorkspaceFileEvent: Event { + public enum Kind: Sendable { + /// One or more paths under the workspace root changed on disk. + /// Paths are workspace-relative, exactly as reported by the event stream. + case filesystemChanged(paths: [String]) + /// A directory's children were lazily indexed into the file cache. + case childrenIndexed + } + + /// Restricts delivery to the workspace at this URL. + public let workspaceURL: URL + public let kind: Kind + + public init(workspaceURL: URL, kind: Kind) { + self.workspaceURL = workspaceURL + self.kind = kind + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/FileEditorOverrides.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/FileEditorOverrides.swift new file mode 100644 index 0000000000..a6e8a1d6a5 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/FileEditorOverrides.swift @@ -0,0 +1,35 @@ +// +// FileEditorOverrides.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation + +/// Read/write seam for a file's editor setting overrides. Lets the File Inspector +/// edit per-file overrides without depending on the Editor feature's +/// `EditorManager` / `CodeFileDocument`. Workspace-scoped: one per window. +public protocol FileEditorOverrides: AnyObject { + /// Current overrides for `file`; all fields `nil` when the file has no open document. + @MainActor + func overrides(for file: CEWorkspaceFile) -> FileEditorOverrideValues + @MainActor + func setIndentOption(_ value: IndentOption?, for file: CEWorkspaceFile) + @MainActor + func setDefaultTabWidth(_ value: Int?, for file: CEWorkspaceFile) + @MainActor + func setWrapLines(_ value: Bool?, for file: CEWorkspaceFile) + @MainActor + func setLanguageId(_ value: String?, for file: CEWorkspaceFile) +} + +/// Default used when no editor is injected (tests, previews); no overrides, no-op writes. +public final class NoOpFileEditorOverrides: FileEditorOverrides { + public init() {} + public func overrides(for file: CEWorkspaceFile) -> FileEditorOverrideValues { .init() } + public func setIndentOption(_ value: IndentOption?, for file: CEWorkspaceFile) {} + public func setDefaultTabWidth(_ value: Int?, for file: CEWorkspaceFile) {} + public func setWrapLines(_ value: Bool?, for file: CEWorkspaceFile) {} + public func setLanguageId(_ value: String?, for file: CEWorkspaceFile) {} +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/FileRelocator.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/FileRelocator.swift new file mode 100644 index 0000000000..98af9373b8 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/FileRelocator.swift @@ -0,0 +1,23 @@ +// +// FileRelocator.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation + +/// Command to move a file within its workspace and reconcile open editor tabs +/// (close tabs for the old location, reopen at the new one). Lets the File +/// Inspector rename/relocate without depending on the Editor feature or holding +/// a `Workspace`. Returns the resolved new file (nil for folders or unresolved workspace). +public protocol FileRelocator: AnyObject { + @MainActor + func relocate(file: CEWorkspaceFile, to destination: URL) throws -> CEWorkspaceFile? +} + +/// Default used when no app shell is present (tests, previews); performs no move. +public final class NoOpFileRelocator: FileRelocator { + public init() {} + public func relocate(file: CEWorkspaceFile, to destination: URL) throws -> CEWorkspaceFile? { nil } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/ShellClientProtocol.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ShellClientProtocol.swift new file mode 100644 index 0000000000..23b2b52492 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/ShellClientProtocol.swift @@ -0,0 +1,48 @@ +// +// ShellClientProtocol.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 11/04/26. +// + +import Combine +import Foundation + +/// Protocol for executing shell commands. +public protocol ShellClientProtocol: Sendable { + /// Run a command synchronously. + /// - Parameter args: Arguments passed to the shell. + /// - Returns: The command output. + @discardableResult + func run(_ args: [String]) throws -> String + + /// Run a command with a Combine publisher for live output. + /// - Parameter args: Arguments passed to the shell. + /// - Returns: A publisher that emits output lines. + @discardableResult + func runLive(_ args: [String]) -> AnyPublisher + + /// Run a command with an async stream for live output. + /// - Parameter args: Arguments passed to the shell. + /// - Returns: An async stream that yields output lines. + func runAsync(_ args: [String]) -> AsyncThrowingStream +} + +public extension ShellClientProtocol { + /// Convenience variadic overload for `run`. + @discardableResult + func run(_ args: String...) throws -> String { + try run(args) + } + + /// Convenience variadic overload for `runLive`. + @discardableResult + func runLive(_ args: String...) -> AnyPublisher { + runLive(args) + } + + /// Convenience variadic overload for `runAsync`. + func runAsync(_ args: String...) -> AsyncThrowingStream { + runAsync(args) + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/TasksConfigurationProviding.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/TasksConfigurationProviding.swift new file mode 100644 index 0000000000..5c6446faf2 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/TasksConfigurationProviding.swift @@ -0,0 +1,19 @@ +// +// TasksConfigurationProviding.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +import Combine + +/// Provides the workspace's configured tasks to consumers that shouldn't know +/// where task configuration is stored (currently `.codeedit/settings.json`, +/// loaded by the app-side `CEWorkspaceSettings`). +public protocol TasksConfigurationProviding: AnyObject { + /// The tasks currently configured for the workspace. + var tasks: [CETask] { get } + + /// Emits the task list whenever the workspace configuration changes. + var tasksPublisher: AnyPublisher<[CETask], Never> { get } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceFileOpener.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceFileOpener.swift new file mode 100644 index 0000000000..38f928335f --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceFileOpener.swift @@ -0,0 +1,22 @@ +// +// WorkspaceFileOpener.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import Foundation + +/// Command interface for opening a file in the workspace that owns it. +/// One rightful handler (the app shell binds it); features request, never resolve. +public protocol WorkspaceFileOpener: AnyObject { + @MainActor + func openFile(at url: URL) +} + +/// Default no-op used until the app registers a real implementation. +public final class NoOpWorkspaceFileOpener: WorkspaceFileOpener { + public init() {} + @MainActor + public func openFile(at url: URL) {} +} diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceNavigator.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceNavigator.swift new file mode 100644 index 0000000000..cc240ea0c5 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceNavigator.swift @@ -0,0 +1,49 @@ +// +// WorkspaceNavigator.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom. +// + +import Foundation + +/// Command interface for opening a file in the workspace that owns it. +/// One rightful handler (the app shell binds it); features request, never resolve. +public protocol WorkspaceNavigator: AnyObject { + /// Open `file` in its owning workspace's active editor. + /// - Parameter asTemporary: open as a temporary (preview) tab, replaced by the next + /// temporary open, rather than a pinned tab. + @MainActor + func open(file: CEWorkspaceFile, asTemporary: Bool) + + /// Resolve `url` to a workspace file and open it in the active editor. + /// + /// For callers that hold a URL rather than a `CEWorkspaceFile` — a package cannot resolve one + /// without depending on the workspace file manager, and expressing intent is the point of this + /// interface. + /// - Parameter asTemporary: open as a temporary (preview) tab, replaced by the next + /// temporary open, rather than a pinned tab. + @MainActor + func open(fileAt url: URL, asTemporary: Bool) + + /// Highlight `file` in the project navigator without opening it. + @MainActor + func reveal(file: CEWorkspaceFile) + + /// Close all tabs showing `file` across every editor split. + @MainActor + func closeTab(file: CEWorkspaceFile) +} + +/// Default no-op used until the app registers a real implementation. +public final class NoOpWorkspaceNavigator: WorkspaceNavigator { + public init() {} + @MainActor + public func open(file: CEWorkspaceFile, asTemporary: Bool) {} + @MainActor + public func open(fileAt url: URL, asTemporary: Bool) {} + @MainActor + public func reveal(file: CEWorkspaceFile) {} + @MainActor + public func closeTab(file: CEWorkspaceFile) {} +} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceStateKey.swift similarity index 80% rename from CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift rename to CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceStateKey.swift index 7a233fe4da..2381eaee0f 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceStateKey.swift @@ -1,11 +1,13 @@ // // WorkspaceStateKey.swift -// CodeEdit +// CodeEditCore // // Created by Khan Winter on 7/3/23. // -enum WorkspaceStateKey: String { +import Foundation + +public enum WorkspaceStateKey: String { case utilityAreaCollapsed case utilityAreaMaximized case utilityAreaHeight diff --git a/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceStatePersisting.swift b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceStatePersisting.swift new file mode 100644 index 0000000000..7f3497e742 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Infrastructure/WorkspaceStatePersisting.swift @@ -0,0 +1,13 @@ +// +// WorkspaceStatePersisting.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 06.04.26. +// + +import Foundation + +public protocol WorkspaceStatePersisting: AnyObject { + func get(_ key: WorkspaceStateKey) -> Any? + func set(key: WorkspaceStateKey, value: Any?) +} diff --git a/CodeEdit/Utils/Extensions/String/String+Escaped.swift b/CodeEditModules/Sources/CodeEditCore/Paths/String+Escaped.swift similarity index 86% rename from CodeEdit/Utils/Extensions/String/String+Escaped.swift rename to CodeEditModules/Sources/CodeEditCore/Paths/String+Escaped.swift index f6ff3109f2..47e665f393 100644 --- a/CodeEdit/Utils/Extensions/String/String+Escaped.swift +++ b/CodeEditModules/Sources/CodeEditCore/Paths/String+Escaped.swift @@ -1,13 +1,13 @@ // -// String+escapedWhiteSpaces.swift -// CodeEdit +// String+Escaped.swift +// CodeEditCore // // Created by Paul Ebose on 2024/07/05. // import Foundation -extension String { +public extension String { /// Escapes the string so it's an always-valid directory func escapedDirectory() -> String { "\"\(self.escapedQuotes())\"" @@ -23,6 +23,7 @@ extension String { escape(replacing: #"""#) } + /// Returns a new string, prefixing every occurrence of the given character with `\` unless already escaped. func escape(replacing: Character) -> String { var string = "" var lastChar: Character? diff --git a/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift b/CodeEditModules/Sources/CodeEditCore/Paths/String+ValidFileName.swift similarity index 81% rename from CodeEdit/Utils/Extensions/String/String+ValidFileName.swift rename to CodeEditModules/Sources/CodeEditCore/Paths/String+ValidFileName.swift index 5ab09cb0fb..0680f223de 100644 --- a/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift +++ b/CodeEditModules/Sources/CodeEditCore/Paths/String+ValidFileName.swift @@ -1,6 +1,6 @@ // // String+ValidFileName.swift -// CodeEdit +// CodeEditCore // // Created by Khan Winter on 1/13/25. // @@ -9,13 +9,13 @@ import Foundation extension CharacterSet { /// On macOS, valid file names must not contain the `NULL` or `:` characters. - static var invalidFileNameCharacters: CharacterSet = CharacterSet(charactersIn: "\0:") + static let invalidFileNameCharacters: CharacterSet = CharacterSet(charactersIn: "\0:") } extension String { /// On macOS, valid file names must not contain the `NULL` or `:` characters, must be non-empty, and must be less /// than 256 UTF16 characters. - var isValidFilename: Bool { + public var isValidFilename: Bool { !isEmpty && CharacterSet(charactersIn: self).isDisjoint(with: .invalidFileNameCharacters) && utf16.count < 256 } } diff --git a/CodeEditModules/Sources/CodeEditCore/Paths/URL+AbsolutePath.swift b/CodeEditModules/Sources/CodeEditCore/Paths/URL+AbsolutePath.swift new file mode 100644 index 0000000000..b8f4d082a4 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Paths/URL+AbsolutePath.swift @@ -0,0 +1,15 @@ +// +// URL+AbsolutePath.swift +// CodeEditCore +// +// Created by Matthijs Eikelenboom on 14/07/2026. +// + +import Foundation + +public extension URL { + /// The non-percent-encoded absolute path. + var absolutePath: String { + absoluteURL.path(percentEncoded: false) + } +} diff --git a/CodeEditModules/Sources/CodeEditCore/Paths/URL+ContainsSubPath.swift b/CodeEditModules/Sources/CodeEditCore/Paths/URL+ContainsSubPath.swift new file mode 100644 index 0000000000..ab4eceb895 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditCore/Paths/URL+ContainsSubPath.swift @@ -0,0 +1,27 @@ +// +// URL+ContainsSubPath.swift +// CodeEditCore +// +// Created by Khan Winter on 10/22/24. +// + +import Foundation + +extension URL { + /// Determines if another URL is lower in the file system than this URL. + /// + /// Examples: + /// ``` + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/file.txt")) // true + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/")) // false + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/")) // false + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/Folder")) // true + /// ``` + /// + /// - Parameter other: The URL to compare. + /// - Returns: True, if the other URL is lower in the file system. + public func containsSubPath(_ other: URL) -> Bool { + other.absoluteString.starts(with: absoluteString) + && other.pathComponents.count > pathComponents.count + } +} diff --git a/CodeEdit/Utils/Extensions/URL/URL+Filename.swift b/CodeEditModules/Sources/CodeEditCore/Paths/URL+FileName.swift similarity index 52% rename from CodeEdit/Utils/Extensions/URL/URL+Filename.swift rename to CodeEditModules/Sources/CodeEditCore/Paths/URL+FileName.swift index be9a2dbd31..c65969fff3 100644 --- a/CodeEdit/Utils/Extensions/URL/URL+Filename.swift +++ b/CodeEditModules/Sources/CodeEditCore/Paths/URL+FileName.swift @@ -1,6 +1,6 @@ // -// URL+Filename.swift -// CodeEdit +// URL+FileName.swift +// CodeEditCore // // Created by Axel Martinez on 5/8/24. // @@ -8,7 +8,8 @@ import Foundation extension URL { - var fileName: String { + /// The last path component with surrounding whitespace and newlines trimmed. + public var fileName: String { self.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/CodeEdit/Utils/Extensions/URL/URL+ResouceValues.swift b/CodeEditModules/Sources/CodeEditCore/Paths/URL+ResourceValues.swift similarity index 66% rename from CodeEdit/Utils/Extensions/URL/URL+ResouceValues.swift rename to CodeEditModules/Sources/CodeEditCore/Paths/URL+ResourceValues.swift index 94285d567b..cca78a0b1c 100644 --- a/CodeEdit/Utils/Extensions/URL/URL+ResouceValues.swift +++ b/CodeEditModules/Sources/CodeEditCore/Paths/URL+ResourceValues.swift @@ -1,6 +1,6 @@ // -// URL+ResouceValues.swift -// CodeEdit +// URL+ResourceValues.swift +// CodeEditCore // // Created by Axel Martinez on 27/6/24. // @@ -8,19 +8,22 @@ import Foundation import UniformTypeIdentifiers -extension URL { +public extension URL { fileprivate var resourceValues: URLResourceValues? { try? self.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .contentTypeKey]) } + /// Whether this URL points to a directory. var isFolder: Bool { resourceValues?.isDirectory ?? false } + /// Whether this URL points to a symbolic link or a Finder alias. var isSymbolicLink: Bool { resourceValues?.isSymbolicLink ?? false || (resourceValues?.contentType ?? .item) == .aliasFile } + /// The uniform type of the item at this URL, or `nil` if the resource value cannot be read. var contentType: UTType? { resourceValues?.contentType } diff --git a/CodeEdit/Features/Search/Extensions/String+SafeOffset.swift b/CodeEditModules/Sources/CodeEditCore/String+SafeOffset.swift similarity index 99% rename from CodeEdit/Features/Search/Extensions/String+SafeOffset.swift rename to CodeEditModules/Sources/CodeEditCore/String+SafeOffset.swift index ff120d91f0..6f31a8d744 100644 --- a/CodeEdit/Features/Search/Extensions/String+SafeOffset.swift +++ b/CodeEditModules/Sources/CodeEditCore/String+SafeOffset.swift @@ -8,7 +8,7 @@ import Foundation /// Some safer alternative methods to ``String. -extension String { +public extension String { /// Safely returns an offset index in a string. /// Use ``safeOffset(_:offsetBy:)`` to default to limiting to the start or end indexes. /// - Parameters: diff --git a/CodeEditModules/Sources/CodeEditDocument/CodeFileDocument+ExternalChanges.swift b/CodeEditModules/Sources/CodeEditDocument/CodeFileDocument+ExternalChanges.swift new file mode 100644 index 0000000000..665843e3c2 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditDocument/CodeFileDocument+ExternalChanges.swift @@ -0,0 +1,71 @@ +// +// CodeFileDocument+ExternalChanges.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 22/08/26. +// + +import AppKit + +/// Reacting to the file changing underneath us on disk. +/// +/// **The isolation here is a bridge, not a resolution.** `NSDocument` is main-actor isolated but +/// declares `read(from:ofType:)` and `presentedItemDidChange()` nonisolated, so both override a +/// nonisolated entry point while touching main-actor document state. `MainActor.assumeIsolated` +/// states an invariant the compiler cannot check, and the `Thread.isMainThread` branch below +/// substitutes a runtime test for a static guarantee. +/// +/// The underlying problem is that ``CodeFileDocument`` mixes main-actor UI state (`content` is an +/// `NSTextStorage` SwiftUI observes) with an I/O lifecycle AppKit drives from arbitrary threads. +/// The proper fix separates the two: decode into a `Sendable` value with no isolation, then install +/// it in one main-actor step. That is a redesign of the document's state ownership, deliberately +/// deferred. See `docs/architecture-decisions.md`, "Document isolation is bridged, not solved". +extension CodeFileDocument { + /// Handle the notification that the represented file item changed. + /// + /// We check if a file has been modified and can be read again to display to the user. + /// To determine if a file has changed, we check the modification date. If it's different from the stored one, + /// we continue. + /// To determine if we can reload the file, we check if the document has outstanding edits. If not, we reload the + /// file. + override public func presentedItemDidChange() { + // Unlike reads, this genuinely arrives on the file-presenter thread, while the state it + // consults is main-actor isolated. This blocks the presenter thread intentionally: if we + // don't wait, we'll receive more updates that the file has changed and end up dispatching + // multiple reads. The presenter thread expects this to be synchronous anyway. + + // https://github.com/CodeEditApp/CodeEdit/issues/2091 + // We can't use `.asyncAndWait` on Ventura as it seems the symbol is missing on that + // platform. Could be just for x86 machines. + let reloadIfClean: @MainActor () -> Bool = { [self] in + guard fileModificationDate != getModificationDate(), !isDocumentEdited else { + return false + } + fileModificationDate = getModificationDate() + if let fileURL, let fileType { + try? read(from: fileURL, ofType: fileType) + } + return true + } + + // Callers are not all off-main: `NSFileCoordinator` delivers on the presenter thread, but + // tests (and any future in-process caller) may already be on the main thread, where + // `DispatchQueue.main.sync` would deadlock. Mirrors the branch in ``notifyLSPDidOpen()``. + let handled = Thread.isMainThread + ? MainActor.assumeIsolated { reloadIfClean() } + : DispatchQueue.main.sync { MainActor.assumeIsolated { reloadIfClean() } } + + if !handled { + super.presentedItemDidChange() + } + } + + /// Helper to find the last modified date of the represented file item. + /// + /// Different from `NSDocument.fileModificationDate`. This returns the *current* modification date, whereas the + /// alternative stores the date that existed when we last read the file. + private func getModificationDate() -> Date? { + guard let path = fileURL?.absolutePath else { return nil } + return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date + } +} diff --git a/CodeEditModules/Sources/CodeEditDocument/CodeFileDocument.swift b/CodeEditModules/Sources/CodeEditDocument/CodeFileDocument.swift new file mode 100644 index 0000000000..f98822266c --- /dev/null +++ b/CodeEditModules/Sources/CodeEditDocument/CodeFileDocument.swift @@ -0,0 +1,387 @@ +// +// CodeFileDocument.swift +// CodeEditModules/CodeFile +// +// Created by Rehatbir Singh on 12/03/2022. +// + +import AppKit +import Foundation +import SwiftUI +import UniformTypeIdentifiers +import CodeEditSourceEditor +import CodeEditTextView +import CodeEditLanguages +import CodeEditCore +import Combine +import OSLog +import TextStory + +enum CodeFileError: Error { + case failedToDecode + case failedToEncode + case fileTypeError +} + +@objc(CodeFileDocument) +public final class CodeFileDocument: NSDocument, ObservableObject { + public struct OpenOptions { + public let cursorPositions: [CursorPosition] + + public init(cursorPositions: [CursorPosition]) { + self.cursorPositions = cursorPositions + } + } + + /// `nonisolated` so the nonisolated overrides can log; `Logger` is `Sendable`. + nonisolated static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CodeFileDocument") + + /// Vends the app-registered delegate (see ``CodeFileDocumentDelegate``). A static provider — + /// not a per-instance property — because framework-created documents fire `documentDidOpen` + /// from `read()` during `init(contentsOf:)`, before any caller could set an instance property. + /// Wired by the app at launch (like ``isAutoSaveOnProvider``); defaults to `nil` so tests and + /// previews are safe. Call sites handle main-actor hops themselves. + nonisolated(unsafe) public static var delegateProvider: () -> CodeFileDocumentDelegate? = { nil } + + /// The app-registered delegate. Provides LSP lifecycle notifications, the standalone-window + /// content view, and undo-manager lookup, keeping this document free of app-tier types. + /// `nil` when unhosted (e.g. tests that don't wire a provider). + private var delegate: CodeFileDocumentDelegate? { Self.delegateProvider() } + + /// The text content of the document, stored as a text storage + /// + /// This is intentionally not a `@Published` variable. If it were published, SwiftUI would do a string + /// compare each time the contents are updated, which could cause a hang on each keystroke if the file is large + /// enough. + /// + /// To receive notifications for content updates, subscribe to one of the publishers on ``contentCoordinator``. + public var content: NSTextStorage? + + /// The string encoding of the original file. Used to save the file back to the encoding it was loaded from. + public var sourceEncoding: FileEncoding? + + /// The coordinator to use to subscribe to edit events and cursor location events. + /// See ``CodeEditSourceEditor/CombineCoordinator``. + @Published public var contentCoordinator: CombineCoordinator = CombineCoordinator() + + /// Used to override detected languages. + @Published public var language: CodeLanguage? + + /// Document-specific overridden indent option. + @Published public var indentOption: CodeEditCore.IndentOption? + + /// Document-specific overridden tab width. + @Published public var defaultTabWidth: Int? + + /// Document-specific overridden line wrap preference. + @Published public var wrapLines: Bool? + + /// The type of data this file document contains. + /// + /// If its text content is not nil, a `text` UTType is returned. + /// + /// - Note: The UTType doesn't necessarily mean the file extension, it can be the MIME + /// type or any other form of data representation. + public var utType: UTType? { + if content != nil { + return .text + } + + guard let fileType, let type = UTType(fileType) else { + return nil + } + + return type + } + + /// Specify options for opening the file such as the initial cursor positions. + /// Nulled by ``CodeFileView`` on first load. + public var openOptions: OpenOptions? + + private let isDocumentEditedSubject = PassthroughSubject() + + /// Publisher for isDocumentEdited property + public var isDocumentEditedPublisher: AnyPublisher { + isDocumentEditedSubject.eraseToAnyPublisher() + } + + /// A lock that ensures autosave scheduling happens correctly. + /// `nonisolated` for the nonisolated `scheduleAutosaving()` override; `NSLock` is `Sendable`. + nonisolated private let autosaveTimerLock: NSLock = NSLock() + + /// Timer used to schedule autosave intervals. + /// `nonisolated(unsafe)` because every access happens with ``autosaveTimerLock`` held — the + /// lock is the synchronisation, which the compiler cannot see. Never touch this outside it. + nonisolated(unsafe) private var autosaveTimer: Timer? + + /// Provides the current "autosave enabled" preference without coupling this type to the + /// Settings feature. Wired by the app at launch (see `AppDelegate`). Defaults to `false` + /// so the type stays self-contained for packaging and predictable in tests that don't wire it. + nonisolated(unsafe) public static var isAutoSaveOnProvider: () -> Bool = { false } + + // MARK: - NSDocument + + override public static var autosavesInPlace: Bool { + isAutoSaveOnProvider() + } + + override public var autosavingFileType: String? { + Self.isAutoSaveOnProvider() ? fileType : nil + } + + override public func makeWindowControllers() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 750, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false + ) + let windowController = NSWindowController(window: window) + if let fileURL { + windowController.shouldCascadeWindows = false + windowController.windowFrameAutosaveName = fileURL.path + } + addWindowController(windowController) + + if let delegate { + window.contentView = delegate.makeWindowContentView(for: self) + } + + window.makeKeyAndOrderFront(nil) + + if let fileURL, UserDefaults.standard.object(forKey: "NSWindow Frame \(fileURL.path)") == nil { + window.center() + } + } + + // MARK: - Data + + override public func data(ofType _: String) throws -> Data { + guard let sourceEncoding, let data = (content?.string as NSString?)?.data(using: sourceEncoding.nsValue) else { + Self.logger.error("Failed to encode contents to \(self.sourceEncoding.debugDescription)") + throw CodeFileError.failedToEncode + } + return data + } + + // MARK: - Read + + /// Never read concurrently. + /// + /// This is AppKit's default, stated explicitly because ``read(from:ofType:)`` depends on it: + /// that method overrides a nonisolated `NSDocument` entry point but touches this document's + /// main-actor state, which is only sound while reads stay on the main thread. Returning `true` + /// here would make that unsound with no compile-time error. + override public static func canConcurrentlyReadDocuments(ofType typeName: String) -> Bool { + false + } + + /// This function is used for decoding files. + /// It should not throw error as unsupported files can still be opened by QLPreviewView. + override public func read(from data: Data, ofType _: String) throws { + var nsString: NSString? + let rawEncoding = NSString.stringEncoding( + for: data, + encodingOptions: [ + .allowLossyKey: false, // Fail if using lossy encoding. + .suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue }, + .useOnlySuggestedEncodingsKey: true + ], + convertedString: &nsString, + usedLossyConversion: nil + ) + guard let validEncoding = FileEncoding(rawEncoding), let nsString else { + Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)") + return + } + let text = nsString as String + let installContents: @MainActor () -> Void = { [self] in + sourceEncoding = validEncoding + if let content { + registerContentChangeUndo(fileURL: fileURL, text: text, content: content) + content.mutableString.setString(text) + } else { + content = NSTextStorage(string: text) + } + notifyLSPDidOpen() + } + + // This overrides a nonisolated `NSDocument` method while everything above touches + // main-actor state. `canConcurrentlyReadDocuments(ofType:)` keeps AppKit's own reads on the + // main thread, but that says nothing about an in-process caller constructing a document off + // it, which has happened before and trapped a bare `assumeIsolated` here. So branch, like + // ``notifyLSPDidOpen()`` and ``presentedItemDidChange()`` do. Unlike those, this blocks: + // `NSDocument` requires the document to be loaded by the time `read` returns, so an async + // hop would return an empty document. + if Thread.isMainThread { + MainActor.assumeIsolated { installContents() } + } else { + DispatchQueue.main.sync { MainActor.assumeIsolated { installContents() } } + } + } + + /// The delegate is main-actor isolated, but document reads and closes can happen off the main + /// thread (AppKit concurrent reads, Swift Testing). Mirrors the NotificationCenter `queue: .main` + /// delivery this replaced: synchronous on main, async hop otherwise. + private func notifyLSPDidOpen() { + if Thread.isMainThread { + MainActor.assumeIsolated { delegate?.documentDidOpen(self) } + } else { + DispatchQueue.main.async { self.delegate?.documentDidOpen(self) } + } + } + + private func notifyLSPDidClose(_ url: URL) { + if Thread.isMainThread { + MainActor.assumeIsolated { delegate?.documentDidClose(at: url) } + } else { + DispatchQueue.main.async { self.delegate?.documentDidClose(at: url) } + } + } + + /// If this file is already open and being tracked by an undo manager, we register an undo mutation + /// of the entire contents. This allows the user to undo changes that occurred outside of CodeEdit + /// while the file was displayed in CodeEdit. + /// + /// - Note: This is inefficient memory-wise. We could do a diff of the file and only register the + /// mutations that would recreate the diff. However, that would instead be CPU intensive. + /// Tradeoffs. + nonisolated private func registerContentChangeUndo(fileURL: URL?, text: String, content: NSTextStorage) { + guard let fileURL else { return } + // The delegate's undo registry is main-actor isolated. Capture only Sendable primitives and build + // the (non-Sendable) `TextMutation` on the main actor so nothing non-Sendable crosses the boundary. + // Re-reads reach here on the main thread; mirror the `queue: .main` bridge used for LSP notifications. + let string = text + let length = content.length + let register: @MainActor () -> Void = { [weak self] in + let mutation = TextMutation( + string: string, + range: NSRange(location: 0, length: length), + limit: length + ) + self?.delegate?.undoManager(forFile: fileURL)?.registerMutation(mutation) + } + if Thread.isMainThread { + MainActor.assumeIsolated { register() } + } else { + DispatchQueue.main.async { register() } + } + } + + // MARK: - Autosave + + /// Triggered when change occurred + override public func updateChangeCount(_ change: NSDocument.ChangeType) { + super.updateChangeCount(change) + + if CodeFileDocument.autosavesInPlace { + return + } + + self.isDocumentEditedSubject.send(self.isDocumentEdited) + } + + /// Triggered when changes saved + override public func updateChangeCount( + withToken changeCountToken: Any, + for saveOperation: NSDocument.SaveOperationType + ) { + super.updateChangeCount(withToken: changeCountToken, for: saveOperation) + + if CodeFileDocument.autosavesInPlace { + return + } + + self.isDocumentEditedSubject.send(self.isDocumentEdited) + } + + /// If ``hasUnautosavedChanges`` is `true` and an autosave has not already been scheduled, schedules a new autosave. + /// If ``hasUnautosavedChanges`` is `false`, cancels any scheduled timers and returns. + /// + /// All operations are done with the ``autosaveTimerLock`` acquired (including the scheduled autosave) to ensure + /// correct timing when scheduling or cancelling timers. + override public func scheduleAutosaving() { + autosaveTimerLock.withLock { + if self.hasUnautosavedChanges { + guard autosaveTimer == nil else { return } + autosaveTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] timer in + // Bound once, rather than optional-chained per access: the isolation below + // cannot state itself for a value the escaping timer block still shares. + guard let document = self else { return } + document.autosaveTimerLock.withLock { + guard timer.isValid else { return } + document.autosaveTimer = nil + // Delivered on the main runloop the timer was scheduled on; assert that + // rather than hopping, which would fire outside the lock. + MainActor.assumeIsolated { + document.autosave(withDelegate: nil, didAutosave: nil, contextInfo: nil) + } + } + } + } else { + autosaveTimer?.invalidate() + autosaveTimer = nil + } + } + } + + // MARK: - Close + + override public func close() { + super.close() + if let fileURL { + notifyLSPDidClose(fileURL) + } + } + + override public func save(_ sender: Any?) { + guard let fileURL else { + super.save(sender) + return + } + + do { + // Get parent directory for cases when entire folders were deleted – and recreate them as needed + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + + super.save(sender) + } catch { + presentError(error) + } + } + + override public func fileNameExtension( + forType typeName: String, + saveOperation: NSDocument.SaveOperationType + ) -> String? { + guard let fileTypeName = Self.fileTypeExtension[typeName] else { + return super.fileNameExtension(forType: typeName, saveOperation: saveOperation) + } + return fileTypeName + } + + /// Determines the code language of the document. + /// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override + /// the file's language. + /// - Returns: The detected code language. + public func getLanguage() -> CodeLanguage { + guard let url = fileURL else { + return .default + } + return language ?? CodeLanguage.detectLanguageFrom( + url: url, + prefixBuffer: content?.string.getFirstLines(5), + suffixBuffer: content?.string.getLastLines(5) + ) + } + +} + +private extension CodeFileDocument { + + /// `nonisolated` so `fileNameExtension(forType:saveOperation:)` can read it off the main actor. + /// `[String: String?]` is `Sendable`, so this is safe rather than merely asserted. + nonisolated static let fileTypeExtension: [String: String?] = [ + "public.make-source": nil + ] +} diff --git a/CodeEditModules/Sources/CodeEditDocument/CodeFileDocumentDelegate.swift b/CodeEditModules/Sources/CodeEditDocument/CodeFileDocumentDelegate.swift new file mode 100644 index 0000000000..5f1976f2ca --- /dev/null +++ b/CodeEditModules/Sources/CodeEditDocument/CodeFileDocumentDelegate.swift @@ -0,0 +1,30 @@ +// +// CodeFileDocumentDelegate.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom. +// + +import AppKit +import CodeEditTextView + +/// The app-provided capabilities a `CodeFileDocument` needs from its host application +/// (undo registry, standalone-window content, and language-server lifecycle). Keeps the +/// document free of `Workspace`, Settings-view, and `LSPService` references. +/// +/// A single shared delegate is wired by the app at launch via +/// ``CodeFileDocument/delegateProvider`` rather than a per-instance `weak var delegate`, +/// so that framework-created documents (`init()` → `read()` fires `documentDidOpen` before +/// any completion handler could set a per-instance delegate) get correct timing without a +/// custom `NSDocumentController`. +@MainActor +public protocol CodeFileDocumentDelegate: AnyObject { + /// The undo manager already registered for a file, if any (nil if none exists yet). + func undoManager(forFile url: URL) -> CEUndoManager? + /// The content view for a standalone single-file window (`makeWindowControllers`). + func makeWindowContentView(for document: CodeFileDocument) -> NSView + /// The document finished reading its contents and is now open. + func documentDidOpen(_ document: CodeFileDocument) + /// The document at `url` closed. + func documentDidClose(at url: URL) +} diff --git a/CodeEdit/Features/Documents/CodeFileDocument/FileEncoding.swift b/CodeEditModules/Sources/CodeEditDocument/FileEncoding.swift similarity index 86% rename from CodeEdit/Features/Documents/CodeFileDocument/FileEncoding.swift rename to CodeEditModules/Sources/CodeEditDocument/FileEncoding.swift index 94da35470c..19f0ac1d9e 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/FileEncoding.swift +++ b/CodeEditModules/Sources/CodeEditDocument/FileEncoding.swift @@ -7,12 +7,12 @@ import Foundation -enum FileEncoding: CaseIterable { +public enum FileEncoding: CaseIterable { case utf8 case utf16BE case utf16LE - var nsValue: UInt { + public var nsValue: UInt { switch self { case .utf8: return NSUTF8StringEncoding @@ -23,7 +23,7 @@ enum FileEncoding: CaseIterable { } } - init?(_ int: UInt) { + public init?(_ int: UInt) { switch int { case NSUTF8StringEncoding: self = .utf8 diff --git a/CodeEditModules/Sources/CodeEditDocument/LanguageServicesProvider.swift b/CodeEditModules/Sources/CodeEditDocument/LanguageServicesProvider.swift new file mode 100644 index 0000000000..d663b7effd --- /dev/null +++ b/CodeEditModules/Sources/CodeEditDocument/LanguageServicesProvider.swift @@ -0,0 +1,70 @@ +// +// LanguageServicesProvider.swift +// CodeEditDocument +// +// Created by Matthijs Eikelenboom on 2026/07/09. +// + +@preconcurrency import CodeEditSourceEditor +import CodeEditTextView +import CodeEditLanguages +import AppKit + +/// The per-document editor integrations supplied by a language service, such as an LSP client. +public struct LanguageServices { + /// Keeps the document's text view in sync with the language tooling as the user edits. + public let textCoordinator: TextViewCoordinator + /// Supplies highlight ranges (e.g. semantic tokens) for the document's text. + public let highlightProvider: any HighlightProviding + + /// Creates a bundle of language services from a text coordinator and a highlight provider. + public init(textCoordinator: TextViewCoordinator, highlightProvider: any HighlightProviding) { + self.textCoordinator = textCoordinator + self.highlightProvider = highlightProvider + } +} + +@MainActor +public protocol LanguageServicesProvider: AnyObject { + func languageServices(for document: CodeFileDocument) -> LanguageServices +} + +public final class NoOpLanguageServicesProvider: LanguageServicesProvider { + nonisolated public init() {} + + @MainActor + public func languageServices(for document: CodeFileDocument) -> LanguageServices { + LanguageServices( + textCoordinator: NoOpTextViewCoordinator(), + highlightProvider: NoOpHighlightProvider() + ) + } +} + +final class NoOpTextViewCoordinator: TextViewCoordinator { + func prepareCoordinator(controller: TextViewController) {} +} + +final class NoOpHighlightProvider: HighlightProviding { + @MainActor + func setUp(textView: TextView, codeLanguage: CodeLanguage) {} + + @MainActor + func applyEdit( + textView: TextView, + range: NSRange, + delta: Int, + completion: @escaping @MainActor (Result) -> Void + ) { + completion(.success(IndexSet())) + } + + @MainActor + func queryHighlightsFor( + textView: TextView, + range: NSRange, + completion: @escaping @MainActor (Result<[HighlightRange], Error>) -> Void + ) { + completion(.success([])) + } +} diff --git a/CodeEdit/Utils/Extensions/String/String+Lines.swift b/CodeEditModules/Sources/CodeEditDocument/String+Lines.swift similarity index 100% rename from CodeEdit/Utils/Extensions/String/String+Lines.swift rename to CodeEditModules/Sources/CodeEditDocument/String+Lines.swift diff --git a/CodeEdit/Features/Settings/Models/GlobPattern.swift b/CodeEditModules/Sources/CodeEditSettings/GlobPattern.swift similarity index 65% rename from CodeEdit/Features/Settings/Models/GlobPattern.swift rename to CodeEditModules/Sources/CodeEditSettings/GlobPattern.swift index 7eb16409fe..bc516a6364 100644 --- a/CodeEdit/Features/Settings/Models/GlobPattern.swift +++ b/CodeEditModules/Sources/CodeEditSettings/GlobPattern.swift @@ -11,10 +11,15 @@ import Foundation /// /// This type does not interpret or validate the glob pattern itself. /// It is simply an identifier (`id`) and the glob pattern string (`value`) associated with it. -struct GlobPattern: Identifiable, Hashable, Decodable, Encodable { +public struct GlobPattern: Identifiable, Hashable, Decodable, Encodable { /// Ephemeral UUID used to uniquely identify this instance in the UI - var id = UUID() + public var id = UUID() /// The Glob Pattern string - var value: String + public var value: String + + public init(id: UUID = UUID(), value: String) { + self.id = id + self.value = value + } } diff --git a/CodeEditModules/Sources/CodeEditSettings/KeyboardShortcutWrapper.swift b/CodeEditModules/Sources/CodeEditSettings/KeyboardShortcutWrapper.swift new file mode 100644 index 0000000000..e5aafdbf10 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/KeyboardShortcutWrapper.swift @@ -0,0 +1,65 @@ +// +// KeyboardShortcutWrapper.swift +// CodeEditSettings +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import SwiftUI + +/// Wrapper for KeyboardShortcut. It contains name, keybindings. +public struct KeyboardShortcutWrapper: Codable, Hashable { + public var keyboardShortcut: KeyboardShortcut { + return KeyboardShortcut.init(.init(Character(keybinding)), modifiers: parsedModifier) + } + + public var parsedModifier: EventModifiers { + switch modifier { + case "command": + return EventModifiers.command + case "shift": + return EventModifiers.shift + case "option": + return EventModifiers.option + case "control": + return EventModifiers.control + default: + return EventModifiers.command + } + } + public var name: String + public var description: String + public var context: String + public var keybinding: String + public var modifier: String + public var id: String + + enum CodingKeys: String, CodingKey { + case name + case description + case context + case keybinding + case modifier + case id + } + + public init(name: String, description: String, context: String, keybinding: String, modifier: String, id: String) { + self.name = name + self.description = description + self.context = context + self.keybinding = keybinding + self.modifier = modifier + self.id = id + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + description = try container.decode(String.self, forKey: .description) + context = try container.decode(String.self, forKey: .context) + keybinding = try container.decode(String.self, forKey: .keybinding) + modifier = try container.decode(String.self, forKey: .modifier) + id = try container.decode(String.self, forKey: .id) + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/DeveloperSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/DeveloperSettings.swift new file mode 100644 index 0000000000..a949a995af --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/DeveloperSettings.swift @@ -0,0 +1,23 @@ +// +// DeveloperSettings.swift +// CodeEdit +// +// Created by Abe Malla on 5/15/24. +// + +import Foundation + +public struct DeveloperSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "developerSettings" + + /// A dictionary that stores a file type and a path to an LSP binary + @CodableDefault public var lspBinaries: [String: String] = [:] + + /// Toggle for showing the internal development inspector + @CodableDefault public var showInternalDevelopmentInspector = false + + /// Default initializer + public init() {} +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/GeneralSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/GeneralSettings.swift new file mode 100644 index 0000000000..8305ae6f14 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/GeneralSettings.swift @@ -0,0 +1,214 @@ +// +// GeneralSettings.swift +// CodeEditModules/Settings +// +// Created by Nanashi Li on 2022/04/08. +// + +import SwiftUI + +/// The general global setting +public struct GeneralSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "general" + + /// The appearance of the app + @CodableDefault public var appAppearance: Appearances = .system + + /// The show issues behavior of the app + @CodableDefault public var showIssues: Issues = .inline + + /// The show live issues behavior of the app + @CodableDefault public var showLiveIssues = true + + /// Show editor jump bar + @CodableDefault public var showEditorJumpBar = true + + /// Dims editors without focus + @CodableDefault public var dimEditorsWithoutFocus = false + + /// The show file extensions behavior of the app + @CodableDefault public var fileExtensionsVisibility: + FileExtensionsVisibility = .showAll + + /// The file extensions collection to display + @CodableDefault public var shownFileExtensions: FileExtensions = .default + + /// The file extensions collection to hide + @CodableDefault public var hiddenFileExtensions: FileExtensions = .default + + /// The style for file icons + @CodableDefault public var fileIconStyle: FileIconStyle = .color + + /// The position for the navigator sidebar tab bar + @CodableDefault public var navigatorTabBarPosition: + SidebarTabBarPosition = .top + + /// The position for the inspector sidebar tab bar + @CodableDefault public var inspectorTabBarPosition: + SidebarTabBarPosition = .top + + /// The reopen behavior of the app + @CodableDefault public var reopenBehavior: ReopenBehavior = .welcome + + /// Decides what the app does after a workspace is closed + @CodableDefault public var reopenWindowAfterClose: + ReopenWindowBehavior = .doNothing + + /// The size of the project navigator + @CodableDefault public var projectNavigatorSize: ProjectNavigatorSize = .medium + + /// The Find Navigator Detail line limit + @CodableDefault public var findNavigatorDetail: NavigatorDetail = .upTo3 + + /// The Issue Navigator Detail line limit + @CodableDefault public var issueNavigatorDetail: NavigatorDetail = .upTo3 + + /// The reveal file in navigator when focus changes behavior of the app. + @CodableDefault public var revealFileOnFocusChange = false + + /// Auto save behavior toggle + @CodableDefault public var isAutoSaveOn = true + + /// Default initializer + public init() {} + + public enum Appearances: String, Codable { + case system + case light + case dark + + /// Applies the selected appearance + /// + /// Main-actor isolated because it mutates `NSApp.appearance`, and AppKit is + /// main-thread-only. Both callers (`AppDelegate` and `GeneralSettingsView`) are + /// already main-actor contexts. + @MainActor + public func applyAppearance() { + switch self { + case .system: + NSApp.appearance = nil + + case .dark: + NSApp.appearance = .init(named: .darkAqua) + + case .light: + NSApp.appearance = .init(named: .aqua) + } + } + } + + /// The style for issues display + /// - **inline**: Issues show inline + /// - **minimized** Issues show minimized + public enum Issues: String, Codable { + case inline + case minimized + } + + /// The style for file extensions visibility + /// - **hideAll**: File extensions are hidden + /// - **showAll** File extensions are visible + /// - **showOnly** Specific file extensions are visible + /// - **hideOnly** Specific file extensions are hidden + public enum FileExtensionsVisibility: Codable, Hashable { + case hideAll + case showAll + case showOnly + case hideOnly + } + + /// The collection of file extensions used by + /// ``FileExtensionsVisibility/showOnly`` or ``FileExtensionsVisibility/hideOnly`` preference + public struct FileExtensions: Codable, Hashable { + public var extensions: [String] + + public var string: String { + get { + extensions.joined(separator: ", ") + } + set { + extensions = newValue + .components(separatedBy: ",") + .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) + .filter({ !$0.isEmpty || string.count < newValue.count }) + } + } + + nonisolated(unsafe) public static var `default` = FileExtensions(extensions: [ + "c", "cc", "cpp", "h", "hpp", "m", "mm", "gif", + "icns", "jpeg", "jpg", "png", "tiff", "swift" + ]) + } + /// The style for file icons + /// - **color**: File icons appear in their default colors + /// - **monochrome**: File icons appear monochromatic + public enum FileIconStyle: String, Codable { + case color + case monochrome + } + + /// The position for a sidebar tab bar + /// - **top**: Tab bar is positioned at the top of the sidebar + /// - **side**: Tab bar is positioned to the side of the sidebar + public enum SidebarTabBarPosition: String, Codable { + case top, side + } + + /// The reopen behavior of the app + /// - **welcome**: On restart the app will show the welcome screen + /// - **openPanel**: On restart the app will show an open panel + /// - **newDocument**: On restart a new empty document will be created + public enum ReopenBehavior: String, Codable { + case welcome + case openPanel + case newDocument + } + + public enum ReopenWindowBehavior: String, Codable { + case showWelcomeWindow + case doNothing + case quit + } + + public enum ProjectNavigatorSize: String, Codable { + case small + case medium + case large + + /// Returns the row height depending on the `projectNavigatorSize` in `Settings`. + /// + /// * `small`: 20 + /// * `medium`: 22 + /// * `large`: 24 + public var rowHeight: Double { + switch self { + case .small: return 20 + case .medium: return 22 + case .large: return 24 + } + } + } + + /// The Navigation Detail behavior of the app + /// - Use **rawValue** to set lineLimit + public enum NavigatorDetail: Int, Codable, CaseIterable { + case upTo1 = 1 + case upTo2 = 2 + case upTo3 = 3 + case upTo4 = 4 + case upTo5 = 5 + case upTo10 = 10 + case upTo30 = 30 + + public var label: String { + switch self { + case .upTo1: + return "One Line" + default: + return "Up to \(self.rawValue) lines" + } + } + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/KeybindingsSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/KeybindingsSettings.swift new file mode 100644 index 0000000000..b17cba532a --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/KeybindingsSettings.swift @@ -0,0 +1,31 @@ +// +// KeybindingsSettings.swift +// CodeEditModules/Settings +// +// Created by Alex on 18.05.2022. +// + +import Foundation + +/// The global settings for text editing +public struct KeybindingsSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "keybindings" + + /// An integer indicating how many spaces a `tab` will generate + public var keybindings: [String: KeyboardShortcutWrapper] = .init() + + /// Default initializer — empty; bundled defaults are seeded by the app at + /// startup via `SettingsData.reconcileDefaultKeybindings()`. + public init() {} + + /// Explicit decoder init for setting default values when key is not present in `JSON` + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.keybindings = try container.decodeIfPresent( + [String: KeyboardShortcutWrapper].self, + forKey: .keybindings + ) ?? .init() + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/NavigationSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/NavigationSettings.swift new file mode 100644 index 0000000000..f02ad26b9e --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/NavigationSettings.swift @@ -0,0 +1,26 @@ +// +// NavigationSettings.swift +// CodeEdit +// +// Created by Austin Condiff on 3/4/24. +// + +import Foundation + +/// The global settings for the terminal emulator +public struct NavigationSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "navigation" + + /// Navigation style used + @CodableDefault public var navigationStyle: NavigationStyle = .openInTabs + + /// Default initializer + public init() {} + + public enum NavigationStyle: String, Codable, Hashable { + case openInTabs + case openInPlace + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/SearchSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/SearchSettings.swift new file mode 100644 index 0000000000..978508ae7a --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/SearchSettings.swift @@ -0,0 +1,20 @@ +// +// SearchSettings.swift +// CodeEdit +// +// Created by Esteban on 12/10/23. +// + +import Foundation + +public struct SearchSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "search" + + /// List of Glob Patterns that determine which files or directories to ignore + @CodableDefault public var ignoreGlobPatterns: [GlobPattern] = [] + + /// Default initializer + public init() {} +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/TextEditingSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/TextEditingSettings.swift new file mode 100644 index 0000000000..0c7f7376b1 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/TextEditingSettings.swift @@ -0,0 +1,254 @@ +// +// TextEditingSettings.swift +// CodeEditModules/Settings +// +// Created by Nanashi Li on 2022/04/08. +// + +import AppKit +import CodeEditCore +import Foundation + +/// The global settings for text editing +public struct TextEditingSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "textEditing" + + /// An integer indicating how many spaces a `tab` will appear as visually. + public var defaultTabWidth: Int = 4 + + /// The behavior of a `tab` keypress. If `.tab`, will insert a tab character. If `.spaces` will insert + /// `.spaceCount` spaces instead. + public var indentOption: IndentOption = IndentOption(indentType: .spaces, spaceCount: 4) + + /// The font to use in editor. + public var font: EditorFont = .init() + + /// A flag indicating whether type-over completion is enabled + public var enableTypeOverCompletion: Bool = true + + /// A flag indicating whether braces are automatically completed + public var autocompleteBraces: Bool = true + + /// A flag indicating whether to wrap lines to editor width + public var wrapLinesToEditorWidth: Bool = true + + /// The percentage of overscroll to apply to the text view + public var overscroll: OverscrollOption = .medium + + /// A multiplier for setting the line height. Defaults to `1.2` + public var lineHeightMultiple: Double = 1.2 + + /// A multiplier for setting the letter spacing, `1` being no spacing and + /// `2` is one character of spacing between letters, defaults to `1`. + public var letterSpacing: Double = 1.0 + + /// The behavior of bracket pair highlights. + public var bracketEmphasis: BracketPairEmphasis = BracketPairEmphasis() + + /// Use the system cursor for the source editor. + public var useSystemCursor: Bool = true + + /// Toggle the gutter in the editor. + public var showGutter: Bool = true + + /// Toggle the minimap in the editor. + public var showMinimap: Bool = true + + /// Toggle the code folding ribbon. + public var showFoldingRibbon: Bool = true + + /// The column at which to reformat text + public var reformatAtColumn: Int = 80 + + /// Show the reformatting guide in the editor + public var showReformattingGuide: Bool = false + + public var invisibleCharacters: InvisibleCharactersConfig = .default + + /// Map of unicode character codes to a note about them + public var warningCharacters: WarningCharacters = .default + + /// Default initializer + public init() {} + + /// Explicit decoder init for setting default values when key is not present in `JSON` + public init(from decoder: Decoder) throws { // swiftlint:disable:this function_body_length + let container = try decoder.container(keyedBy: CodingKeys.self) + self.defaultTabWidth = try container.decodeIfPresent(Int.self, forKey: .defaultTabWidth) ?? 4 + self.indentOption = try container.decodeIfPresent( + IndentOption.self, + forKey: .indentOption + ) ?? IndentOption(indentType: .spaces, spaceCount: 4) + self.font = try container.decodeIfPresent(EditorFont.self, forKey: .font) ?? .init() + self.enableTypeOverCompletion = try container.decodeIfPresent( + Bool.self, + forKey: .enableTypeOverCompletion + ) ?? true + self.autocompleteBraces = try container.decodeIfPresent( + Bool.self, + forKey: .autocompleteBraces + ) ?? true + self.wrapLinesToEditorWidth = try container.decodeIfPresent( + Bool.self, + forKey: .wrapLinesToEditorWidth + ) ?? true + self.overscroll = try container.decodeIfPresent( + OverscrollOption.self, + forKey: .overscroll + ) ?? .medium + self.lineHeightMultiple = try container.decodeIfPresent( + Double.self, + forKey: .lineHeightMultiple + ) ?? 1.2 + self.letterSpacing = try container.decodeIfPresent( + Double.self, + forKey: .letterSpacing + ) ?? 1 + self.bracketEmphasis = try container.decodeIfPresent( + BracketPairEmphasis.self, + forKey: .bracketEmphasis + ) ?? BracketPairEmphasis() + if #available(macOS 14, *) { + self.useSystemCursor = try container.decodeIfPresent(Bool.self, forKey: .useSystemCursor) ?? true + } else { + self.useSystemCursor = false + } + + self.showGutter = try container.decodeIfPresent(Bool.self, forKey: .showGutter) ?? true + self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true + self.showFoldingRibbon = try container.decodeIfPresent(Bool.self, forKey: .showFoldingRibbon) ?? true + self.reformatAtColumn = try container.decodeIfPresent(Int.self, forKey: .reformatAtColumn) ?? 80 + self.showReformattingGuide = try container.decodeIfPresent( + Bool.self, + forKey: .showReformattingGuide + ) ?? false + self.invisibleCharacters = try container.decodeIfPresent( + InvisibleCharactersConfig.self, + forKey: .invisibleCharacters + ) ?? .default + self.warningCharacters = try container.decodeIfPresent( + WarningCharacters.self, + forKey: .warningCharacters + ) ?? .default + } + + /// Re-exported from `CodeEditCore`. Keeps `TextEditingSettings.IndentOption` + /// valid for all existing call sites while the underlying type lives in the Core package. + public typealias IndentOption = CodeEditCore.IndentOption + + public struct BracketPairEmphasis: Codable, Hashable { + /// The type of highlight to use + public var highlightType: HighlightType = .flash + public var useCustomColor: Bool = false + /// The color to use for the highlight. + public var color: Theme.Attributes = Theme.Attributes(color: "FFFFFF", bold: false, italic: false) + + public enum HighlightType: String, Codable { + case disabled + case bordered + case flash + case underline + } + } + + public enum OverscrollOption: String, Codable { + case none + case small + case medium + case large + + public var overscrollPercentage: CGFloat { + switch self { + case .none: return 0 + case .small: return 0.25 + case .medium: return 0.5 + case .large: return 0.75 + } + } + } + + public struct InvisibleCharactersConfig: Equatable, Hashable, Codable { + nonisolated(unsafe) public static var `default`: InvisibleCharactersConfig = { + InvisibleCharactersConfig( + enabled: false, + showSpaces: true, + showTabs: true, + showLineEndings: true + ) + }() + + public var enabled: Bool + + public var showSpaces: Bool + public var showTabs: Bool + public var showLineEndings: Bool + + public var spaceReplacement: String = "·" + public var tabReplacement: String = "→" + + // Controlled by `showLineEndings` + public var carriageReturnReplacement: String = "↵" + public var lineFeedReplacement: String = "¬" + public var paragraphSeparatorReplacement: String = "¶" + public var lineSeparatorReplacement: String = "⏎" + } + + public struct WarningCharacters: Equatable, Hashable, Codable { + nonisolated(unsafe) public static let `default`: WarningCharacters = + WarningCharacters(enabled: true, characters: [ + 0x0003: "End of text", + + 0x00A0: "Non-breaking space", + 0x202F: "Narrow non-breaking space", + 0x200B: "Zero-width space", + 0x200C: "Zero-width non-joiner", + 0x2029: "Paragraph separator", + + 0x2013: "Em-dash", + 0x00AD: "Soft hyphen", + + 0x2018: "Left single quote", + 0x2019: "Right single quote", + 0x201C: "Left double quote", + 0x201D: "Right double quote", + + 0x037E: "Greek Question Mark" + ]) + + public var enabled: Bool + public var characters: [UInt16: String] + } + + public struct EditorFont: Codable, Hashable { + /// The font size for the font + public var size: Double = 12 + + /// The name of the custom font + public var name: String = "SF Mono" + + /// The weight of the custom font + public var weight: NSFont.Weight = .medium + + /// Default initializer + public init() {} + + /// Explicit decoder init for setting default values when key is not present in `JSON` + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.size = try container.decodeIfPresent(Double.self, forKey: .size) ?? size + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? name + self.weight = try container.decodeIfPresent(NSFont.Weight.self, forKey: .weight) ?? weight + } + + /// Returns an NSFont representation of the current configuration. + /// + /// Returns the custom font, if enabled and able to be instantiated. + /// Otherwise returns a default system font monospaced. + public var current: NSFont { + let customFont = NSFont(name: name, size: size)?.withWeight(weight: weight) + return customFont ?? NSFont.monospacedSystemFont(ofSize: size, weight: .medium) + } + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Models/ThemeSettings.swift b/CodeEditModules/Sources/CodeEditSettings/Models/ThemeSettings.swift new file mode 100644 index 0000000000..c7fdef9f05 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Models/ThemeSettings.swift @@ -0,0 +1,98 @@ +// +// ThemeSettings.swift +// CodeEditModules/Settings +// +// Created by Nanashi Li on 2022/04/08. +// + +import CodeEditCore +import Foundation + +/// A dictionary containing the keys and associated ``Theme/Attributes`` of overridden properties +/// +/// ```json +/// { +/// "editor" : { +/// "background" : { +/// "color" : "#123456" +/// }, +/// ... +/// }, +/// "terminal" : { +/// "blue" : { +/// "color" : "#1100FF" +/// }, +/// ... +/// } +/// } +/// ``` +public typealias ThemeOverrides = [String: [String: Theme.Attributes]] + +/// The global settings for themes +public struct ThemeSettings: SettingsSection { + + /// The top-level key this section occupies in `settings.json`. + public static let settingsKey = "theme" + + /// The name of the currently selected dark theme + public var selectedDarkTheme: String = "Default (Dark)" + + /// The name of the currently selected light theme + public var selectedLightTheme: String = "Default (Light)" + + /// The name of the currently selected theme + public var selectedTheme: String? + + /// Use the system background that matches the appearance setting + public var useThemeBackground: Bool = true + + /// Automatically change theme based on system appearance + public var matchAppearance: Bool = true + + /// Dictionary of themes containing overrides + /// + /// ```json + /// { + /// "overrides" : { + /// "DefaultDark" : { + /// "editor" : { + /// "background" : { + /// "color" : "#123456" + /// }, + /// ... + /// }, + /// "terminal" : { + /// "blue" : { + /// "color" : "#1100FF" + /// }, + /// ... + /// } + /// ... + /// }, + /// ... + /// }, + /// ... + /// } + /// ``` + public var overrides: [String: ThemeOverrides] = [:] + + /// Default initializer + public init() {} + + /// Explicit decoder init for setting default values when key is not present in `JSON` + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.selectedDarkTheme = try container.decodeIfPresent( + String.self, forKey: .selectedDarkTheme + ) ?? selectedDarkTheme + self.selectedLightTheme = try container.decodeIfPresent( + String.self, forKey: .selectedLightTheme + ) ?? selectedLightTheme + self.selectedTheme = try container.decodeIfPresent(String.self, forKey: .selectedTheme) + self.useThemeBackground = try container.decodeIfPresent(Bool.self, forKey: .useThemeBackground) ?? true + self.matchAppearance = try container.decodeIfPresent( + Bool.self, forKey: .matchAppearance + ) ?? true + self.overrides = try container.decodeIfPresent([String: ThemeOverrides].self, forKey: .overrides) ?? [:] + } +} diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/NSFont+WithWeight.swift b/CodeEditModules/Sources/CodeEditSettings/NSFont+WithWeight.swift similarity index 95% rename from CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/NSFont+WithWeight.swift rename to CodeEditModules/Sources/CodeEditSettings/NSFont+WithWeight.swift index e3b7871f1b..e79d283900 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/NSFont+WithWeight.swift +++ b/CodeEditModules/Sources/CodeEditSettings/NSFont+WithWeight.swift @@ -10,7 +10,7 @@ import SwiftUI extension NSFont { /// Rough mapping from behavior of .systemFont(…weight:) /// to NSFontManager's Int-based weight, as of 13.4 Ventura - func withWeight(weight: NSFont.Weight) -> NSFont? { + public func withWeight(weight: NSFont.Weight) -> NSFont? { let fontManager = NSFontManager.shared var intWeight: Int diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/CodableDefault+Providers.swift b/CodeEditModules/Sources/CodeEditSettings/Store/CodableDefault+Providers.swift new file mode 100644 index 0000000000..d3183fd972 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/CodableDefault+Providers.swift @@ -0,0 +1,80 @@ +// +// CodableDefault+Providers.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 07.04.26. +// + +// MARK: - Bool Defaults + +public enum DefaultTrue: DefaultValueProvider { + public static let defaultValue = true +} + +public enum DefaultFalse: DefaultValueProvider { + public static let defaultValue = false +} + +// MARK: - Navigation Defaults + +public enum DefaultNavigationStyle: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = NavigationSettings.NavigationStyle.openInTabs +} + +// MARK: - Collection Defaults + +public enum DefaultEmptyGlobPatterns: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue: [GlobPattern] = [] +} + +public enum DefaultEmptyStringDictionary: DefaultValueProvider { + public static let defaultValue: [String: String] = [:] +} + +// MARK: - Account Defaults + +public enum DefaultEmptyString: DefaultValueProvider { + public static let defaultValue = "" +} + +// MARK: - General Settings Defaults + +public enum DefaultAppearance: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.Appearances.system +} + +public enum DefaultIssues: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.Issues.inline +} + +public enum DefaultFileExtensionsVisibility: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.FileExtensionsVisibility.showAll +} + +public enum DefaultFileExtensions: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.FileExtensions.default +} + +public enum DefaultFileIconStyle: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.FileIconStyle.color +} + +public enum DefaultSidebarTabBarPositionTop: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.SidebarTabBarPosition.top +} + +public enum DefaultReopenBehavior: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.ReopenBehavior.welcome +} + +public enum DefaultReopenWindowBehavior: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.ReopenWindowBehavior.doNothing +} + +public enum DefaultProjectNavigatorSize: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.ProjectNavigatorSize.medium +} + +public enum DefaultNavigatorDetail: DefaultValueProvider { + nonisolated(unsafe) public static let defaultValue = GeneralSettings.NavigatorDetail.upTo3 +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/CodableDefault.swift b/CodeEditModules/Sources/CodeEditSettings/Store/CodableDefault.swift new file mode 100644 index 0000000000..0c27156f9a --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/CodableDefault.swift @@ -0,0 +1,64 @@ +// +// CodableDefault.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 07.04.26. +// + +import Foundation + +/// A type that provides a default value for a ``CodableDefault`` property wrapper. +/// +/// Conform to this protocol to define a default value that will be used +/// when decoding fails or the key is missing from the JSON. +public protocol DefaultValueProvider { + /// The type of the value being defaulted; must round-trip through `Codable`. + associatedtype Value: Codable & Hashable + /// The fallback value used when the key is missing from the JSON or its value fails to decode. + static var defaultValue: Value { get } +} + +/// A property wrapper that provides a default value when decoding from JSON +/// and the key is missing or the value cannot be decoded. +/// +/// Usage: +/// ```swift +/// struct MySettings: Codable, Hashable { +/// @CodableDefault var isEnabled: Bool +/// @CodableDefault var isHidden: Bool +/// } +/// ``` +/// +/// With this wrapper, you no longer need a custom `init(from:)` for handling +/// missing keys — Swift's auto-synthesized decoder handles it automatically. +@propertyWrapper +public struct CodableDefault: Codable, Hashable { + public var wrappedValue: Provider.Value + + public init(wrappedValue: Provider.Value) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + wrappedValue = (try? container.decode(Provider.Value.self)) ?? Provider.defaultValue + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +// MARK: - KeyedDecodingContainer Support + +/// When a key is missing from the JSON, return the provider's default value +/// instead of throwing a `DecodingError.keyNotFound`. +extension KeyedDecodingContainer { + func decode( + _ type: CodableDefault

.Type, + forKey key: Key + ) throws -> CodableDefault

{ + (try? decodeIfPresent(type, forKey: key)) ?? CodableDefault(wrappedValue: P.defaultValue) + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/JSONValue.swift b/CodeEditModules/Sources/CodeEditSettings/Store/JSONValue.swift new file mode 100644 index 0000000000..d4ea79eb6c --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/JSONValue.swift @@ -0,0 +1,58 @@ +// +// JSONValue.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 10/08/26. +// + +import Foundation + +/// A parsed JSON tree, used to hold settings sections nothing is registered to decode. +/// +/// This is what lets a disabled, uninstalled or not-yet-loaded extension's configuration survive a +/// save: the store re-emits these verbatim rather than dropping keys it does not understand. +public enum JSONValue: Codable, Hashable, Sendable { + case null + case bool(Bool) + case integer(Int) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + // Order matters. `Bool` before the numerics: Foundation will decode `true` as `1` if a + // numeric type is tried first, which would rewrite booleans as numbers on save. `Int` + // before `Double`: decoding `42` as `Double` re-emits it as `42` today but loses the + // integer/float distinction the file was written with. + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .integer(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .integer(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/PersistentSettingsStore.swift b/CodeEditModules/Sources/CodeEditSettings/Store/PersistentSettingsStore.swift new file mode 100644 index 0000000000..d69bbe17f6 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/PersistentSettingsStore.swift @@ -0,0 +1,170 @@ +// +// PersistentSettingsStore.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 10/08/26. +// + +import Combine +import Foundation + +/// The app's single settings store: owns the on-disk state, the save pipeline and the seam's +/// invalidation signal. +/// +/// Constructed and owned by `AppDependencies`, the app-scope composition root — this type has no +/// `shared`. It replaces `Settings.shared`, which reached the same state from anywhere and could +/// therefore never be substituted in a test or a second configuration. +/// +/// Not `@MainActor`: it conforms to ``SettingsAccessing``, which is nonisolated. The original reason +/// — that the protocol had to supply a nonisolated `EnvironmentKey` default — is gone with that key. +/// What still blocks isolation is the Swift 5 app target, where annotating the protocol cascades +/// into its callers, so main-thread use is asserted at the write entry point instead. Isolating both +/// belongs with the app-target Swift 6 migration. +public final class PersistentSettingsStore: ObservableObject, SettingsAccessing { + + /// Section-keyed storage. Sections nothing here decodes are held verbatim and re-emitted on + /// save, so a disabled extension's configuration survives. + private let store: SettingsStore + + /// One element per write. Throttled rather than debounced so a continuous stream of changes + /// (dragging a font-size slider) still reaches disk at a bounded interval instead of only when + /// the user stops. + private let saveRequests = PassthroughSubject() + + private var saveTask: AnyCancellable? + + /// A counter incremented once per change to any section. + /// + /// Kept for **AppKit** consumers, which cannot observe an `ObservableObject` and instead watch + /// this counter to know when to reload. + /// + /// SwiftUI no longer needs it: views observe this store directly through ``SettingsValue``, so + /// the change signal is the object's own. It previously had to be a separate `Equatable` + /// environment key, because the accessor was injected as an existential that never compares + /// unequal to itself — leaving re-render to depend on SwiftUI treating a rewritten + /// non-`Equatable` value as a change, which is unspecified. + @Published public private(set) var revision: Int = 0 + + /// `~/Library/Application Support/CodeEdit/` — the folder settings and adjacent app data live in. + public var baseURL: URL { SettingsLocation.baseURL } + + /// The file this store loads from and saves to. Injectable so a test can point a store at a + /// temporary file instead of the user's real `settings.json`. + private let settingsURL: URL + + /// One copy-aside per store, however many sections turn out to be undecodable: the copy is of the + /// whole file, so the first one already contains every one of them. + private var hasPreservedOriginal = false + + public init(settingsURL: URL = SettingsLocation.settingsFileURL) { + self.settingsURL = settingsURL + self.store = Self.loadStore(at: settingsURL) + + // The whole-file copy above only covers a file that failed to *load*. A file that loads fine + // but holds one section this build cannot decode is the same loss at a smaller scale: the + // section reads as defaults, and the first write of it replaces the user's JSON with values + // they never chose. `SettingsStore` announces exactly that moment, before it happens and + // while the original is still on disk, so the same copy-aside applies. + self.store.willReplaceUndecodableSection = { [weak self] _ in + guard let self, !self.hasPreservedOriginal else { return } + self.hasPreservedOriginal = true + Self.preserveUnreadableFile(at: settingsURL) + } + + self.saveTask = saveRequests + .throttle(for: 2, scheduler: RunLoop.main, latest: true) + .sink { [weak self] in + try? self?.save() + } + } + + // MARK: - SettingsAccessing + + public func value(_ type: S.Type) -> S { + store[S.self] + } + + public func setValue(_ value: S) { + // `SettingsAccessing` is nonisolated (see the protocol's docs), so the compiler cannot + // enforce this yet — the Swift 5 app target is the remaining blocker, not the environment + // key this store used to be injected through. A write publishes to SwiftUI observers and + // bumps `revision` for AppKit ones; off the main thread that corrupts AppKit state rather + // than failing cleanly. Loud in debug, unchanged in release. + MainActor.assertIsolated("Settings must be written on the main thread") + + store[S.self] = value + // Mutate first, then publish: a view body re-evaluated by this revision change already reads + // the new value. `Settings` achieved the same ordering by bumping on `$preferences`' + // `willSet`; doing it explicitly after the store write makes the guarantee local instead of + // dependent on Combine's emission timing. + revision &+= 1 + saveRequests.send() + } + + // MARK: - Persistence + + /// Builds the store from `settings.json`, or an empty one when the file is absent or unreadable. + /// + /// The two failure cases are **not** equivalent and are deliberately handled differently. A file + /// that is *absent* means a first launch, and an empty store is the correct answer. A file that + /// is *present but unreadable* means the user has settings that something here failed to parse — + /// a bug in this code, a half-written file, a manual edit with a stray comma. Answering with an + /// empty store is still the only way to keep launching, but the first subsequent write would + /// then persist that emptiness over the original, destroying settings that were very likely + /// recoverable by hand. + /// + /// So the original is copied aside first, and the copy is what makes the fallback survivable. + /// The alternative considered — refusing to save until the user explicitly re-saves — was + /// rejected: it turns one silent failure into another (every later change is dropped with no + /// indication), and it still loses the file the moment anything does write. + private static func loadStore(at url: URL) -> SettingsStore { + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: url.path) else { + try? fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: false + ) + return SettingsStore() + } + + guard let json = try? Data(contentsOf: url), + let loaded = try? SettingsStore(data: json) + else { + preserveUnreadableFile(at: url) + return SettingsStore() + } + return loaded + } + + /// Copies an unreadable `settings.json` to `settings.json.corrupt-` before anything + /// can overwrite it. + /// + /// Called from two places: a whole file that failed to load, and — via + /// `SettingsStore.willReplaceUndecodableSection` — the first write that would replace a single + /// section this build could not decode. + /// + /// Copied rather than moved, so the app still finds a file where it expects one, and so a + /// failure to copy cannot itself destroy the original. `copyItem` is used rather than re-reading + /// the bytes because the file may have failed to *read*, not merely to parse. + private static func preserveUnreadableFile(at url: URL) { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + // Colon-free: legal on APFS, but a filename with colons reads as a path separator in the + // Finder and in plenty of shell tooling, and this file exists to be found and inspected. + formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss'Z'" + + let backup = url.appendingPathExtension("corrupt-\(formatter.string(from: Date()))") + guard !FileManager.default.fileExists(atPath: backup.path) else { return } + try? FileManager.default.copyItem(at: url, to: backup) + } + + /// Writes every section — including the ones nothing here decodes — to `settings.json`. + /// + /// The write is `.atomic` so an interrupted save cannot leave a truncated file where the user's + /// settings used to be. + private func save() throws { + try store.encoded().write(to: settingsURL, options: .atomic) + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/SettingsAccessing.swift b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsAccessing.swift new file mode 100644 index 0000000000..b923796c79 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsAccessing.swift @@ -0,0 +1,28 @@ +// +// SettingsAccessing.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 09/08/26. +// + +/// Read *and* write access to settings, one section at a time. +/// +/// The write half of the settings seam. It extends ``SettingsReading`` rather than standing beside +/// it as a separate `SettingsWriting`, because a read-write consumer — most importantly +/// ``SettingsValue``'s `projectedValue`, which must produce a `Binding` — needs both halves from a +/// *single* value. Two protocols would mean two environment keys, and a view could then be handed a +/// live reader next to a defaulted writer: reads would look correct while writes silently vanished. +/// One protocol makes that state unrepresentable. +/// +/// Consumers that only read should keep depending on ``SettingsReading``; every `SettingsAccessing` +/// satisfies it, so narrowing costs nothing. +/// +/// Deliberately neither `Sendable` nor `@MainActor`, for the same reasons documented on +/// ``SettingsReading``. +public protocol SettingsAccessing: SettingsReading { + /// Stores `value`, replacing the whole section it belongs to. + /// + /// Section-granular by design: a caller changing one field reads the section, mutates it and + /// writes it back, so it never has to name the app-wide settings aggregate. + func setValue(_ value: S) +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/SettingsLocation.swift b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsLocation.swift new file mode 100644 index 0000000000..d839216dde --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsLocation.swift @@ -0,0 +1,30 @@ +// +// SettingsLocation.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 10/08/26. +// + +import Foundation + +/// Where CodeEdit's user-editable configuration lives on disk. +/// +/// Namespaced constants, not a service: a filesystem path has no state, no lifetime and nothing to +/// inject. It was previously reachable only through `Settings.shared.baseURL`, which made every +/// consumer of the *path* a consumer of the *singleton*. Splitting it out is what let that singleton +/// be deleted without inventing an injection channel for a constant. +public enum SettingsLocation { + /// `~/Library/Application Support/CodeEdit/` + public static var baseURL: URL { + FileManager.default + .homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) + } + + /// `~/Library/Application Support/CodeEdit/settings.json` + public static var settingsFileURL: URL { + baseURL + .appending(path: "settings") + .appendingPathExtension("json") + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/SettingsSection.swift b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsSection.swift new file mode 100644 index 0000000000..80725bb23a --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsSection.swift @@ -0,0 +1,22 @@ +// +// SettingsSection.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 09/08/26. +// + +/// One independently-stored group of settings, addressed by a stable key. +/// +/// The key **is** the top-level key in `settings.json`. Changing it orphans every existing user's +/// values for that section, so treat it as a published contract rather than an implementation +/// detail. +/// +/// Deliberately not `Sendable`: `TerminalSettings.Font` carries an `NSFont.Weight`, and requiring +/// `Sendable` here would cascade into `CEEditor`, which is still Swift 5. +public protocol SettingsSection: Codable, Hashable { + /// The top-level key this section occupies in `settings.json`. + static var settingsKey: String { get } + + /// A section must be constructible at its defaults, for when the key is absent from the file. + init() +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/SettingsStore.swift b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsStore.swift new file mode 100644 index 0000000000..31f8095a4e --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsStore.swift @@ -0,0 +1,98 @@ +// +// SettingsStore.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 10/08/26. +// + +import Foundation + +/// Settings storage keyed by section. +/// +/// Sections nothing is registered to read are held as ``JSONValue`` and re-emitted verbatim on +/// save. That is what makes an unknown or disabled extension's configuration safe: the previous +/// store decoded only known keys and wrote only known keys, so a file written by a newer build lost +/// keys when an older build saved. +/// +/// A section that is present but *undecodable* is read as its defaults, but is likewise held and +/// re-emitted verbatim — so a hand-edit this build cannot parse survives every save until something +/// writes that same section back. That write is announced through +/// ``willReplaceUndecodableSection`` so the owner can preserve the original first. +/// +/// Not `@MainActor`: it is constructed and used from the main actor in practice, but isolating it +/// would break the deliberately-nonisolated ``SettingsAccessing`` conformance added in Task 3. +public final class SettingsStore { + + private var sections: [String: JSONValue] + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + /// Called with the section key immediately before a write replaces a stored value that this + /// build could **not** decode. + /// + /// This is the store's one destructive moment, and the only one. A section that fails to decode + /// reads as `S()`, but the raw JSON stays in `sections` and ``encoded()`` re-emits it verbatim, + /// so the user's original survives every save — right up until something writes *that* section + /// back, which replaces it with values that were never theirs. Nothing else in this type can lose + /// data. + /// + /// Deliberately a callback rather than a policy: this target has no business knowing where the + /// file lives or what "preserve" means. The owner (`PersistentSettingsStore`) copies the file aside. + /// Called synchronously and before the replacement, so the handler still sees the original both + /// in this store and on disk. + public var willReplaceUndecodableSection: ((String) -> Void)? + + /// Keys already reported, so a section written repeatedly reports once. + private var reportedUndecodableSections: Set = [] + + /// An empty store, with every section at its defaults. + public init() { + self.sections = [:] + } + + /// Loads a store from a previously-encoded `settings.json`. + public init(data: Data) throws { + self.sections = try JSONDecoder().decode([String: JSONValue].self, from: data) + } + + /// Reads or writes the section for `type`, falling back to its defaults when absent or + /// undecodable. + public subscript(_ type: S.Type) -> S { + get { + guard let raw = sections[S.settingsKey], let decoded = decode(raw, as: S.self) else { + return S() + } + return decoded + } + set { + guard let data = try? encoder.encode(newValue), + let raw = try? decoder.decode(JSONValue.self, from: data) + else { + assertionFailure("Section '\(S.settingsKey)' failed to round-trip through JSONValue") + return + } + // Report *before* the assignment: this is the one point at which a value the user wrote + // by hand, and this build could not read, stops existing. + if let existing = sections[S.settingsKey], + decode(existing, as: S.self) == nil, + reportedUndecodableSections.insert(S.settingsKey).inserted { + willReplaceUndecodableSection?(S.settingsKey) + } + sections[S.settingsKey] = raw + } + } + + /// Decodes one stored section, or `nil` when this build cannot read it. + private func decode(_ raw: JSONValue, as type: S.Type) -> S? { + guard let data = try? encoder.encode(raw) else { return nil } + return try? decoder.decode(S.self, from: data) + } + + /// Every section read from disk, including ones nothing is registered to decode. + public func encoded() throws -> Data { + let data = try encoder.encode(sections) + let object = try JSONSerialization.jsonObject(with: data) + return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + } +} diff --git a/CodeEditModules/Sources/CodeEditSettings/Store/SettingsValue.swift b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsValue.swift new file mode 100644 index 0000000000..a00311574a --- /dev/null +++ b/CodeEditModules/Sources/CodeEditSettings/Store/SettingsValue.swift @@ -0,0 +1,145 @@ +// +// SettingsValue.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 09/08/26. +// + +import Foundation +import SwiftUI + +/// Read access to settings, one section at a time. +/// +/// Feature packages depend on this rather than on the concrete store, so they never name the +/// app-wide aggregate and never reach a singleton. +/// +/// This is the seam for **non-SwiftUI** consumers — models, services, AppKit controllers — which take +/// it by initializer. SwiftUI views observe ``PersistentSettingsStore`` directly through +/// ``SettingsValue`` instead, because `@EnvironmentObject` cannot carry a protocol existential. +/// +/// Deliberately neither `Sendable` nor `@MainActor`: conforming types include a store class whose +/// methods cannot be actor-isolated without breaking this conformance. The other former reason — +/// that `EnvironmentKey` requires a nonisolated static default — no longer applies, since that key +/// is gone. What still blocks isolation is the Swift 5 app target, where annotating this protocol +/// cascades into its callers. +public protocol SettingsReading { + /// The current value of `type`, or its defaults if the section is absent. + func value(_ type: S.Type) -> S +} + +/// A reader that answers with defaults and **discards every write**, trapping in debug. +/// +/// This was the environment key's fallback. That key is gone — a view with no store now traps +/// immediately — but the type survives for its *other* role: the stand-in that pre-existing +/// singletons (`ThemeModel`, `FeedbackModel`, `SearchSettingsModel`, `HistoryInspectorModel`) hold +/// between construction and `configure(_:)`. Being used at all still means a wiring bug, so both +/// methods `assertionFailure` unless `XCODE_RUNNING_FOR_PREVIEWS` is set. +/// +/// The discarding write is the dangerous half: a consumer that never received a real store reads +/// plausible defaults and *appears* to save, losing the user's change with no error. +public struct DefaultSettingsReader: SettingsAccessing { + /// Previews legitimately render with no store configured; everywhere else, reaching this type + /// is a wiring bug worth a debug trap. + private static var isRunningInPreviews: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + public init() {} + + /// Answers with defaults. Reads are only *misleading*, not destructive, so they trap in debug + /// but the value is still returned — a preview or a mis-wired consumer keeps working. + public func value(_ type: S.Type) -> S { + if !Self.isRunningInPreviews { + assertionFailure( + "Read of '\(S.settingsKey)' fell back to defaults: this consumer never received a " + + "settings store. Singletons receive one through `configure(_:)` at launch." + ) + } + return S() + } + + /// Discards `value`. See the type's documentation — this is a no-op, not a save. + public func setValue(_ value: S) { + if !Self.isRunningInPreviews { + assertionFailure( + "Write to '\(S.settingsKey)' was discarded: this consumer never received a settings " + + "store. Singletons receive one through `configure(_:)` at launch." + ) + } + } +} + +/// A fixed reader for tests and SwiftUI previews. +public struct SnapshotSettingsReader: SettingsReading { + private let sections: [String: any SettingsSection] + + public init(_ sections: [String: any SettingsSection]) { + self.sections = sections + } + + public func value(_ type: S.Type) -> S { + sections[S.settingsKey] as? S ?? S() + } +} + +/// Reads and writes one property of one settings section inside a SwiftUI view. +/// +/// ```swift +/// @SettingsValue(TerminalSettings.self, \.cursorBlink) private var cursorBlink +/// @SettingsValue(TextEditingSettings.self, \.showMinimap) private var showMinimap +/// Toggle("Show Minimap", isOn: $showMinimap) +/// ``` +/// +/// Only valid inside a `View`. AppKit types must be handed the value by their +/// `NSViewRepresentable` instead — read at the SwiftUI boundary, pass by value inward. +/// +/// The key path is a `WritableKeyPath` even for read-only uses: every settings field is a `var`, so +/// requiring it costs read-only call sites nothing and keeps one property wrapper for both jobs. +/// +/// **A missing injection traps.** This replaced a pair of environment *keys* — one for the value, +/// one for an `Int` invalidation signal — whose failure modes were both silent: a subtree given +/// neither read plausible defaults and discarded writes, and a subtree given the value but not the +/// signal read correctly and never re-rendered. `@EnvironmentObject` makes both unrepresentable. +/// SwiftUI subscribes to the object itself, so there is no second key to forget and nothing to keep +/// in sync. +/// +/// **Main-actor isolated deliberately, and it must stay that way.** +/// +/// `EnvironmentObject`'s initialiser and wrapped value are `@MainActor` in the SDK, so a +/// nonisolated wrapper touching them fails under Swift 6 strict concurrency. Newer SwiftUI carries +/// `@preconcurrency` annotations that hide this, which is why it compiled on Xcode 26 and failed on +/// the CI runner's Xcode 16.4. The isolation is also true on the merits: this is documented as valid +/// only inside a `View`, and `PersistentSettingsStore.setValue` already asserts the main thread. +/// Same shape as SwiftUI's own `@StateObject` and `@ObservedObject`. +@propertyWrapper +@MainActor +public struct SettingsValue: DynamicProperty { + @EnvironmentObject private var store: PersistentSettingsStore + + private let keyPath: WritableKeyPath + + public init(_ section: S.Type, _ keyPath: WritableKeyPath) { + self._store = EnvironmentObject() + self.keyPath = keyPath + } + + public var wrappedValue: Value { + get { store.value(S.self)[keyPath: keyPath] } + // Read-modify-write of the whole section: the store is section-granular, and this is the + // only way to change one field without naming the settings aggregate. + nonmutating set { + var section = store.value(S.self) + section[keyPath: keyPath] = newValue + store.setValue(section) + } + } + + /// A binding to the setting, for controls like `Toggle` and `TextField`. + public var projectedValue: Binding { + Binding { + wrappedValue + } set: { + wrappedValue = $0 + } + } +} diff --git a/CodeEdit/Utils/Extensions/Color/Color+HEX.swift b/CodeEditModules/Sources/CodeEditUI/Color+HEX.swift similarity index 54% rename from CodeEdit/Utils/Extensions/Color/Color+HEX.swift rename to CodeEditModules/Sources/CodeEditUI/Color+HEX.swift index 8a258d9dd6..3e8a80cf74 100644 --- a/CodeEdit/Utils/Extensions/Color/Color+HEX.swift +++ b/CodeEditModules/Sources/CodeEditUI/Color+HEX.swift @@ -1,18 +1,14 @@ // // Color+HEX.swift -// CodeEditModules/CodeEditUtils +// CodeEditUI // // Created by Lukas Pistrol on 23.03.22. // import SwiftUI -extension Color { - - /// Initializes a `Color` from a HEX String (e.g.: `#1D2E3F`) and an optional alpha value. - /// - Parameters: - /// - hex: A String of a HEX representation of a color (format: `#1D2E3F`) - /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` +public extension Color { + /// Creates a color from a hex string such as `#AABBCC`; surrounding non-alphanumeric characters are ignored. init(hex: String, alpha: Double = 1.0) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 @@ -20,10 +16,7 @@ extension Color { self.init(hex: Int(int), alpha: alpha) } - /// Initializes a `Color` from an Int (e.g.: `0x1D2E3F`)and an optional alpha value. - /// - Parameters: - /// - hex: An Int of a HEX representation of a color (format: `0x1D2E3F`) - /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` + /// Creates a color in the sRGB color space from a packed `0xRRGGBB` integer and an optional alpha. init(hex: Int, alpha: Double = 1.0) { let red = (hex >> 16) & 0xFF let green = (hex >> 8) & 0xFF @@ -31,36 +24,28 @@ extension Color { self.init(.sRGB, red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, opacity: alpha) } - /// Returns an Int representing the `Color` in hex format (e.g.: 0x112233) + /// The color's RGB components packed into a single `0xRRGGBB` integer; alpha is not included. var hex: Int { guard let components = cgColor?.components, components.count >= 3 else { return 0 } - let red = lround((Double(components[0]) * 255.0)) << 16 let green = lround((Double(components[1]) * 255.0)) << 8 let blue = lround((Double(components[2]) * 255.0)) - return red | green | blue } - /// Returns a HEX String representing the `Color` (e.g.: #112233) + /// The color formatted as a lowercase web-style hex string, e.g. `#aabbcc`. var hexString: String { - let color = self.hex - - return "#" + String(format: "%06x", color) + "#" + String(format: "%06x", hex) } - /// The alpha (opacity) component of the Color (0.0 - 1.0) + /// The color's alpha (opacity) component, in the range `0...1`. var alphaComponent: Double { NSColor(self).alphaComponent } } -extension NSColor { - - /// Initializes a `NSColor` from a HEX String (e.g.: `#1D2E3F`) and an optional alpha value. - /// - Parameters: - /// - hex: A String of a HEX representation of a color (format: `#1D2E3F`) - /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` +public extension NSColor { + /// Creates a color from a hex string such as `#AABBCC`; surrounding non-alphanumeric characters are ignored. convenience init(hex: String, alpha: Double = 1.0) { let hex = hex.trimmingCharacters(in: .alphanumerics.inverted) var int: UInt64 = 0 @@ -68,10 +53,7 @@ extension NSColor { self.init(hex: Int(int), alpha: alpha) } - /// Initializes a `NSColor` from an Int (e.g.: `0x1D2E3F`)and an optional alpha value. - /// - Parameters: - /// - hex: An Int of a HEX representation of a color (format: `0x1D2E3F`) - /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` + /// Creates a color in the sRGB color space from a packed `0xRRGGBB` integer and an optional alpha. convenience init(hex: Int, alpha: Double = 1.0) { let red = (hex >> 16) & 0xFF let green = (hex >> 8) & 0xFF @@ -79,21 +61,17 @@ extension NSColor { self.init(srgbRed: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, alpha: alpha) } - /// Returns an Int representing the `NSColor` in hex format (e.g.: 0x112233) + /// The color's RGB components packed into a single `0xRRGGBB` integer; alpha is not included. var hex: Int { guard let components = cgColor.components, components.count >= 3 else { return 0 } - let red = lround((Double(components[0]) * 255.0)) << 16 let green = lround((Double(components[1]) * 255.0)) << 8 let blue = lround((Double(components[2]) * 255.0)) - return red | green | blue } - /// Returns a HEX String representing the `NSColor` (e.g.: #112233) + /// The color formatted as a lowercase web-style hex string, e.g. `#aabbcc`. var hexString: String { - let color = self.hex - - return "#" + String(format: "%06x", color) + "#" + String(format: "%06x", hex) } } diff --git a/CodeEdit/Utils/Environment/Env+IsFullscreen.swift b/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+IsFullscreen.swift similarity index 62% rename from CodeEdit/Utils/Environment/Env+IsFullscreen.swift rename to CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+IsFullscreen.swift index eb1c93bf63..2e3473231c 100644 --- a/CodeEdit/Utils/Environment/Env+IsFullscreen.swift +++ b/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+IsFullscreen.swift @@ -1,6 +1,6 @@ // -// Env+IsFullscreen.swift -// CodeEdit +// Environment+IsFullscreen.swift +// CodeEditUI // // Created by Wouter Hennen on 14/01/2023. // @@ -11,7 +11,9 @@ private struct WorkspaceFullscreenStateEnvironmentKey: EnvironmentKey { static let defaultValue: Bool = false } -extension EnvironmentValues { +public extension EnvironmentValues { + /// Whether the window hosting this view is in fullscreen, so views can adapt their layout (e.g. the + /// toolbar inset). var isFullscreen: Bool { get { self[WorkspaceFullscreenStateEnvironmentKey.self] } set { self[WorkspaceFullscreenStateEnvironmentKey.self] = newValue } diff --git a/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+ModifierKeys.swift b/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+ModifierKeys.swift new file mode 100644 index 0000000000..468eba69fa --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+ModifierKeys.swift @@ -0,0 +1,21 @@ +// +// Environment+ModifierKeys.swift +// CodeEditUI +// +// Created by Wouter Hennen on 04/03/2023. +// + +import SwiftUI + +public struct EventModifierEnvironmentKey: EnvironmentKey { + nonisolated(unsafe) public static var defaultValue: NSEvent.ModifierFlags = [] +} + +public extension EnvironmentValues { + /// The modifier keys (command, option, shift, ...) currently held down, for views that adapt while + /// a modifier is pressed. + var modifierKeys: EventModifierEnvironmentKey.Value { + get { self[EventModifierEnvironmentKey.self] } + set { self[EventModifierEnvironmentKey.self] = newValue } + } +} diff --git a/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+Window.swift b/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+Window.swift new file mode 100644 index 0000000000..50162aac96 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/EnvironmentKeys/Environment+Window.swift @@ -0,0 +1,31 @@ +// +// Environment+Window.swift +// CodeEditUI +// +// Created by Wouter Hennen on 14/01/2023. +// + +import SwiftUI + +/// Wraps a weak `NSWindow` reference so it can travel through the SwiftUI environment without the +/// environment retaining the window. +public struct WindowBox { + /// The boxed window; nil once the window is gone or when none was injected. + public weak var value: NSWindow? + /// Creates a box holding a weak reference to the given window. + public init(value: NSWindow? = nil) { self.value = value } +} + +public struct NSWindowEnvironmentKey: EnvironmentKey { + public typealias Value = WindowBox + nonisolated(unsafe) public static var defaultValue = WindowBox(value: nil) +} + +public extension EnvironmentValues { + /// The `NSWindow` hosting this view hierarchy, boxed to avoid retaining it. Injected by the window + /// controller at the SwiftUI root; the box is empty in previews. + var window: WindowBox { + get { self[NSWindowEnvironmentKey.self] } + set { self[NSWindowEnvironmentKey.self] = newValue } + } +} diff --git a/CodeEditModules/Sources/CodeEditUI/FileIcon.swift b/CodeEditModules/Sources/CodeEditUI/FileIcon.swift new file mode 100644 index 0000000000..713a18af20 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/FileIcon.swift @@ -0,0 +1,181 @@ +// +// FileIcon.swift +// CodeEditUI +// +// Created by Matthijs Eikelenboom on 05/08/2026. +// + +import SwiftUI +import UniformTypeIdentifiers +import CodeEditSymbols + +/// The symbol and tint used to represent a file or folder. +public struct FileIconSpec: Sendable, Equatable { + public let symbol: String + public let color: Color + + public init(symbol: String, color: Color) { + self.symbol = symbol + self.color = color + } +} + +public extension FileIconSpec { + /// SwiftUI image, preferring a `CodeEditSymbols` custom symbol. + var image: Image { + if let custom = NSImage.symbol(named: symbol) { + return Image(nsImage: custom) + } + return Image(systemName: symbol) + } + + /// AppKit image, preferring a `CodeEditSymbols` custom symbol. + var nsImage: NSImage { + if let custom = NSImage.symbol(named: symbol) { + return custom + } + return NSImage(systemSymbolName: symbol, accessibilityDescription: symbol) + ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! + } +} + +/// Maps files to a symbol and tint. +/// +/// Keyed on the file's *name*, not on a domain type, so this stays free of model +/// dependencies and can live beside the other presentation atoms. +public enum FileIcon { + + // Resolved from this package's own asset catalog, so the package is + // self-contained and its tests can assert colours without an app host. + private static let amber = Color("Amber", bundle: .module) + private static let scarlet = Color("Scarlet", bundle: .module) + private static let steel = Color("Steel", bundle: .module) + + /// Used when no file is known. Matches the old `fileIcon(fileType: nil)`. + public static let generic = FileIconSpec(symbol: "doc", color: Color("Steel", bundle: .module)) + + /// Whole-filename matches, checked before any extension. + private static let byFilename: [String: FileIconSpec] = [ + "LICENSE": .init(symbol: "key.fill", color: amber), + "Makefile": .init(symbol: "terminal", color: Color(red: 0.937, green: 0.325, blue: 0.314)) + ] + + private static let byExtension: [String: FileIconSpec] = [ + // structured data + "json": .init(symbol: "doc.json", color: scarlet), + "yml": .init(symbol: "doc.json", color: scarlet), + "resolved": .init(symbol: "doc.json", color: scarlet), + "strings": .init(symbol: "text.quote", color: scarlet), + "plist": .init(symbol: "tablecells", color: steel), + "lock": .init(symbol: "lock.doc", color: steel), + // web + "css": .init(symbol: "curlybraces", color: .teal), + "html": .init(symbol: "chevron.left.forwardslash.chevron.right", color: .orange), + "js": .init(symbol: "doc.javascript", color: amber), + "mjs": .init(symbol: "doc.javascript", color: amber), + "ts": .init(symbol: "t.square", color: .blue), + "jsx": .init(symbol: "atom", color: .cyan), + "tsx": .init(symbol: "atom", color: .cyan), + "vue": .init(symbol: "v.square", color: Color(red: 0.255, green: 0.722, blue: 0.514)), + // languages + "swift": .init(symbol: "swift", color: .orange), + "java": .init(symbol: "cup.and.saucer", color: .blue), + "py": .init(symbol: "doc.python", color: amber), + "rb": .init(symbol: "doc.ruby", color: scarlet), + "c": .init(symbol: "c.square", color: .purple), + "h": .init(symbol: "h.square", color: Color(red: 0.667, green: 0.031, blue: 0.133)), + "m": .init(symbol: "m.square", color: Color(red: 0.271, green: 0.106, blue: 0.525)), + "go": .init(symbol: "g.square", color: Color(red: 0.02, green: 0.675, blue: 0.757)), + "sum": .init(symbol: "s.square", color: Color(red: 0.925, green: 0.251, blue: 0.478)), + "mod": .init(symbol: "m.square", color: Color(red: 0.925, green: 0.251, blue: 0.478)), + "rs": .init(symbol: "r.square", color: .orange), + // shells + "bash": .init(symbol: "terminal", color: steel), + "sh": .init(symbol: "terminal", color: steel), + "zsh": .init(symbol: "terminal", color: steel), + "scpt": .init(symbol: "applescript", color: steel), + // config + "env": .init(symbol: "gearshape.fill", color: steel), + "example": .init(symbol: "gearshape.fill", color: steel), + "gitignore": .init(symbol: "arrow.triangle.branch", color: steel), + "entitlements": .init(symbol: "checkmark.seal", color: amber), + "xcconfig": .init(symbol: "gearshape.2", color: steel), + "cetheme": .init(symbol: "paintbrush", color: .purple), + // media + "png": .init(symbol: "photo", color: .blue), + "jpg": .init(symbol: "photo", color: .blue), + "jpeg": .init(symbol: "photo", color: .blue), + "ico": .init(symbol: "photo", color: .blue), + "svg": .init(symbol: "square.fill.on.circle.fill", color: .blue), + "pdf": .init(symbol: "photo", color: steel), + "wav": .init(symbol: "speaker.wave.2", color: steel), + "mp3": .init(symbol: "speaker.wave.2", color: steel), + "aif": .init(symbol: "speaker.wave.2", color: steel), + "mid": .init(symbol: "speaker.wave.2", color: steel), + "avi": .init(symbol: "film", color: steel), + "mp4": .init(symbol: "film", color: steel), + "mov": .init(symbol: "film", color: steel), + // documents + "rtf": .init(symbol: "doc.richtext", color: steel), + "md": .init(symbol: "doc.plaintext", color: steel), + "txt": .init(symbol: "doc.plaintext", color: steel), + "text": .init(symbol: "doc.plaintext", color: steel) + ].merging( + // Known plain-text languages. Explicit entries, not fallbacks — without + // them these would regress to bare `doc`. + [ + "adb", "clj", "cls", "cs", "d", "dart", "elm", "ex", "f95", "fs", "gs", + "hs", "jl", "kt", "l", "lsp", "lua", "mk", "pas", "pl", "scm", "ss" + ].reduce(into: [:]) { dict, ext in + dict[ext] = FileIconSpec(symbol: "doc.plaintext", color: steel) + }, + uniquingKeysWith: { current, _ in current } + ) + + /// Spec for the file at `url`. + public static func spec(for url: URL) -> FileIconSpec { + let filename = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + + if let match = byFilename[filename] { + return match + } + + // Leading-dot names (.gitignore, .env.example) — drop the dot, then treat + // the remainder as an extension chain. + let searchable = filename.hasPrefix(".") ? String(filename.dropFirst()) : filename + + // Last extension wins: `file.d.ts` resolves `ts` before `d`. + for component in searchable.components(separatedBy: ".").reversed() { + if let match = byExtension[component] { + return match + } + } + + return systemSpec(for: url) ?? FileIconSpec(symbol: "doc", color: steel) + } + + /// Spec for a folder. + public static func folderSpec( + isEmpty: Bool, + isRoot: Bool, + isCodeEditDirectory: Bool + ) -> FileIconSpec { + if isRoot || isCodeEditDirectory { + return .init(symbol: "folder.fill.badge.gearshape", color: steel) + } + return .init(symbol: isEmpty ? "folder" : "folder.fill", color: steel) + } + + /// Fallback for extensions the table does not cover, using the system's + /// declared type. Keeps unrecognised-but-identifiable files meaningful. + private static func systemSpec(for url: URL) -> FileIconSpec? { + guard let type = UTType(filenameExtension: url.pathExtension) else { return nil } + if type.conforms(to: .image) { return .init(symbol: "photo", color: steel) } + if type.conforms(to: .audio) { return .init(symbol: "speaker.wave.2", color: steel) } + if type.conforms(to: .audiovisualContent) { return .init(symbol: "film", color: steel) } + if type.conforms(to: .sourceCode) || type.conforms(to: .text) { + return .init(symbol: "doc.plaintext", color: steel) + } + return nil + } +} diff --git a/CodeEditModules/Sources/CodeEditUI/LayoutMetrics.swift b/CodeEditModules/Sources/CodeEditUI/LayoutMetrics.swift new file mode 100644 index 0000000000..105b49a066 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/LayoutMetrics.swift @@ -0,0 +1,21 @@ +// +// LayoutMetrics.swift +// CodeEditUI +// +// Created by Matthijs Eikelenboom. +// + +import CoreGraphics + +/// Shared layout constants that multiple features need to agree on. +public enum LayoutMetrics { + /// The fixed height of the workspace window's status bar, in points. + /// Taller on macOS 26, matching the Tahoe status bar. + public static var statusBarHeight: CGFloat { + if #available(macOS 26, *) { + 37.0 + } else { + 28.0 + } + } +} diff --git a/CodeEdit/Assets.xcassets/Custom Colors/Amber.colorset/Contents.json b/CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Amber.colorset/Contents.json similarity index 100% rename from CodeEdit/Assets.xcassets/Custom Colors/Amber.colorset/Contents.json rename to CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Amber.colorset/Contents.json diff --git a/CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Contents.json b/CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CodeEdit/Assets.xcassets/Custom Colors/Scarlet.colorset/Contents.json b/CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Scarlet.colorset/Contents.json similarity index 100% rename from CodeEdit/Assets.xcassets/Custom Colors/Scarlet.colorset/Contents.json rename to CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Scarlet.colorset/Contents.json diff --git a/CodeEdit/Assets.xcassets/Custom Colors/Steel.colorset/Contents.json b/CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Steel.colorset/Contents.json similarity index 100% rename from CodeEdit/Assets.xcassets/Custom Colors/Steel.colorset/Contents.json rename to CodeEditModules/Sources/CodeEditUI/Resources/Colors.xcassets/Steel.colorset/Contents.json diff --git a/CodeEdit/Features/About/BlurButtonStyle.swift b/CodeEditModules/Sources/CodeEditUI/Styles/BlurButtonStyle.swift similarity index 79% rename from CodeEdit/Features/About/BlurButtonStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Styles/BlurButtonStyle.swift index a86f21bcfe..5721f7529a 100644 --- a/CodeEdit/Features/About/BlurButtonStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Styles/BlurButtonStyle.swift @@ -1,19 +1,21 @@ // // BlurButtonStyle.swift -// CodeEdit +// CodeEditUI // // Created by Wouter Hennen on 21/01/2023. // import SwiftUI -extension ButtonStyle where Self == BlurButtonStyle { +public extension ButtonStyle where Self == BlurButtonStyle { + /// A button style with a translucent, blurred material background, for buttons overlaid on content. static var blur: BlurButtonStyle { BlurButtonStyle() } + /// A more subdued variant of ``blur``, for the less prominent action next to a primary blur button. static var secondaryBlur: BlurButtonStyle { BlurButtonStyle(isSecondary: true) } } -struct BlurButtonStyle: ButtonStyle { - var isSecondary: Bool = false +public struct BlurButtonStyle: ButtonStyle { + var isSecondary: Bool @Environment(\.controlSize) var controlSize @@ -21,6 +23,10 @@ struct BlurButtonStyle: ButtonStyle { @Environment(\.colorScheme) var colorScheme + public init(isSecondary: Bool = false) { + self.isSecondary = isSecondary + } + var height: CGFloat { switch controlSize { case .large: @@ -30,7 +36,7 @@ struct BlurButtonStyle: ButtonStyle { } } - func makeBody(configuration: Configuration) -> some View { + public func makeBody(configuration: Configuration) -> some View { configuration.label .padding(.horizontal, 8) .frame(height: height) diff --git a/CodeEdit/Features/CodeEditUI/Styles/CapsuleButtonStyle.swift b/CodeEditModules/Sources/CodeEditUI/Styles/CapsuleButtonStyle.swift similarity index 83% rename from CodeEdit/Features/CodeEditUI/Styles/CapsuleButtonStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Styles/CapsuleButtonStyle.swift index 50ae176a73..6e6aad257f 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/CapsuleButtonStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Styles/CapsuleButtonStyle.swift @@ -7,27 +7,27 @@ import SwiftUI -struct CapsuleButtonStyle: ButtonStyle { +public struct CapsuleButtonStyle: ButtonStyle { var isActive: Bool? var font: Font? var width: CGFloat? var height: CGFloat? - init(isActive: Bool? = nil, font: Font? = nil, size: CGFloat? = nil) { + public init(isActive: Bool? = nil, font: Font? = nil, size: CGFloat? = nil) { self.isActive = isActive self.font = font self.width = size self.height = size } - init(isActive: Bool? = nil, font: Font? = nil, width: CGFloat?, height: CGFloat?) { + public init(isActive: Bool? = nil, font: Font? = nil, width: CGFloat?, height: CGFloat?) { self.isActive = isActive self.font = font self.width = width self.height = height } - func makeBody(configuration: ButtonStyle.Configuration) -> some View { + public func makeBody(configuration: ButtonStyle.Configuration) -> some View { CapsuleButton( configuration: configuration, isActive: isActive, @@ -51,7 +51,7 @@ struct CapsuleButtonStyle: ButtonStyle { var width: CGFloat? var height: CGFloat? - init( + public init( configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, @@ -88,7 +88,8 @@ struct CapsuleButtonStyle: ButtonStyle { } } -extension ButtonStyle where Self == CapsuleButtonStyle { +public extension ButtonStyle where Self == CapsuleButtonStyle { + /// A square capsule icon button of `size` on each edge. static func capsuleIcon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), @@ -96,6 +97,7 @@ extension ButtonStyle where Self == CapsuleButtonStyle { ) -> CapsuleButtonStyle { return CapsuleButtonStyle(isActive: isActive, font: font, size: size) } + /// A capsule icon button sized to `size`. static func capsuleIcon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), @@ -103,6 +105,7 @@ extension ButtonStyle where Self == CapsuleButtonStyle { ) -> CapsuleButtonStyle { return CapsuleButtonStyle(isActive: isActive, font: font, width: size?.width, height: size?.height) } + /// A capsule icon button with independently specified dimensions. static func capsuleIcon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), @@ -111,11 +114,13 @@ extension ButtonStyle where Self == CapsuleButtonStyle { ) -> CapsuleButtonStyle { return CapsuleButtonStyle(isActive: isActive, font: font, width: width, height: height) } + /// A capsule icon button that sizes itself to its content. static func capsuleIcon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) ) -> CapsuleButtonStyle { return CapsuleButtonStyle(isActive: isActive, font: font) } + /// A capsule icon button with default styling. static var capsuleIcon: CapsuleButtonStyle { .init() } } diff --git a/CodeEdit/Features/CodeEditUI/Styles/IconButtonStyle.swift b/CodeEditModules/Sources/CodeEditUI/Styles/IconButtonStyle.swift similarity index 85% rename from CodeEdit/Features/CodeEditUI/Styles/IconButtonStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Styles/IconButtonStyle.swift index 13628a28f8..b1c6417c75 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/IconButtonStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Styles/IconButtonStyle.swift @@ -7,7 +7,7 @@ import SwiftUI -struct IconButtonStyle: ButtonStyle { +public struct IconButtonStyle: ButtonStyle { var isActive: Bool? var font: Font? var width: CGFloat? @@ -27,7 +27,7 @@ struct IconButtonStyle: ButtonStyle { self.height = height } - func makeBody(configuration: ButtonStyle.Configuration) -> some View { + public func makeBody(configuration: ButtonStyle.Configuration) -> some View { IconButton( configuration: configuration, isActive: isActive, @@ -88,7 +88,8 @@ struct IconButtonStyle: ButtonStyle { } } -extension ButtonStyle where Self == IconButtonStyle { +public extension ButtonStyle where Self == IconButtonStyle { + /// An icon button style with a custom font, active state, and fixed square frame of the given side length. static func icon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), @@ -96,6 +97,7 @@ extension ButtonStyle where Self == IconButtonStyle { ) -> IconButtonStyle { return IconButtonStyle(isActive: isActive, font: font, size: size) } + /// An icon button style with a custom font, active state, and fixed frame of the given width and height. static func icon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), @@ -103,6 +105,7 @@ extension ButtonStyle where Self == IconButtonStyle { ) -> IconButtonStyle { return IconButtonStyle(isActive: isActive, font: font, width: size?.width, height: size?.height) } + /// An icon button with independently specified dimensions. static func icon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), @@ -111,11 +114,13 @@ extension ButtonStyle where Self == IconButtonStyle { ) -> IconButtonStyle { return IconButtonStyle(isActive: isActive, font: font, width: width, height: height) } + /// An icon button style with a custom font and active state, and no fixed frame. static func icon( isActive: Bool? = false, font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) ) -> IconButtonStyle { return IconButtonStyle(isActive: isActive, font: font) } + /// An icon button style with the default font and no fixed frame. static var icon: IconButtonStyle { .init() } } diff --git a/CodeEdit/Features/CodeEditUI/Styles/IconToggleStyle.swift b/CodeEditModules/Sources/CodeEditUI/Styles/IconToggleStyle.swift similarity index 73% rename from CodeEdit/Features/CodeEditUI/Styles/IconToggleStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Styles/IconToggleStyle.swift index 2382dfc346..c0dcaf9149 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/IconToggleStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Styles/IconToggleStyle.swift @@ -7,7 +7,7 @@ import SwiftUI -struct IconToggleStyle: ToggleStyle { +public struct IconToggleStyle: ToggleStyle { var font: Font? var size: CGSize? @@ -28,7 +28,7 @@ struct IconToggleStyle: ToggleStyle { self.size = nil } - func makeBody(configuration: ToggleStyle.Configuration) -> some View { + public func makeBody(configuration: ToggleStyle.Configuration) -> some View { Button( action: { configuration.isOn.toggle() }, label: { configuration.label } @@ -37,23 +37,27 @@ struct IconToggleStyle: ToggleStyle { } } -extension ToggleStyle where Self == IconToggleStyle { +public extension ToggleStyle where Self == IconToggleStyle { + /// An icon toggle style with a custom font and a fixed square frame of the given side length. static func icon( font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), size: CGFloat? = 24 ) -> IconToggleStyle { return IconToggleStyle(font: font, size: size) } + /// An icon toggle style with a custom font and a fixed frame of the given width and height. static func icon( font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), size: CGSize? = CGSize(width: 24, height: 24) ) -> IconToggleStyle { return IconToggleStyle(font: font, size: size) } + /// An icon toggle style with a custom font and no fixed frame. static func icon( font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) ) -> IconToggleStyle { return IconToggleStyle(font: font) } + /// An icon toggle style with the default font and no fixed frame. static var icon: IconToggleStyle { .init() } } diff --git a/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift b/CodeEditModules/Sources/CodeEditUI/Styles/OverlayButtonStyle.swift similarity index 84% rename from CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Styles/OverlayButtonStyle.swift index 7e9b962c68..4d03301bd0 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Styles/OverlayButtonStyle.swift @@ -1,11 +1,11 @@ import SwiftUI /// A button style for overlay buttons (like close, action buttons in notifications) -struct OverlayButtonStyle: ButtonStyle { +public struct OverlayButtonStyle: ButtonStyle { @Environment(\.colorScheme) private var colorScheme - func makeBody(configuration: Configuration) -> some View { + public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 10)) .foregroundColor(.secondary) @@ -26,7 +26,7 @@ struct OverlayButtonStyle: ButtonStyle { } } -extension ButtonStyle where Self == OverlayButtonStyle { +public extension ButtonStyle where Self == OverlayButtonStyle { /// A button style for overlay buttons static var overlay: OverlayButtonStyle { OverlayButtonStyle() diff --git a/CodeEdit/Features/CodeEditUI/Styles/MenuWithButtonStyle.swift b/CodeEditModules/Sources/CodeEditUI/Views/ButtonStyledMenu.swift similarity index 76% rename from CodeEdit/Features/CodeEditUI/Styles/MenuWithButtonStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Views/ButtonStyledMenu.swift index 2e432354f4..880593355a 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/MenuWithButtonStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/ButtonStyledMenu.swift @@ -1,5 +1,5 @@ // -// MenuWithButtonStyle.swift +// ButtonStyledMenu.swift // CodeEdit // // Created by Tommy Ludwig on 08.09.24. @@ -8,10 +8,16 @@ import SwiftUI /// A menu styled to resemble a bordered button. -struct MenuWithButtonStyle: View { +public struct ButtonStyledMenu: View { var systemImage: String var menu: () -> MenuView - var body: some View { + + public init(systemImage: String, menu: @escaping () -> MenuView) { + self.systemImage = systemImage + self.menu = menu + } + + public var body: some View { Menu { menu() } label: {} .background { Button {} label: { diff --git a/CodeEdit/Features/ActivityViewer/Notifications/CECircularProgressView.swift b/CodeEditModules/Sources/CodeEditUI/Views/CECircularProgressView.swift similarity index 89% rename from CodeEdit/Features/ActivityViewer/Notifications/CECircularProgressView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/CECircularProgressView.swift index e6580f36d0..d3d37f6637 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/CECircularProgressView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/CECircularProgressView.swift @@ -1,13 +1,13 @@ // // CECircularProgressView.swift -// CodeEdit +// CodeEditUI // // Created by Tommy Ludwig on 21.06.24. // import SwiftUI -struct CECircularProgressView: View { +public struct CECircularProgressView: View { @State private var isAnimating = false @State private var previousValue: Bool = false @@ -16,7 +16,12 @@ struct CECircularProgressView: View { let lineWidth: CGFloat = 2 - var body: some View { + public init(progress: Double? = nil, currentTaskCount: Int = 1) { + self.progress = progress + self.currentTaskCount = currentTaskCount + } + + public var body: some View { Circle() .stroke(style: StrokeStyle(lineWidth: lineWidth)) .foregroundStyle(.tertiary) diff --git a/CodeEdit/Features/CodeEditUI/Views/CEContentUnavailableView.swift b/CodeEditModules/Sources/CodeEditUI/Views/CEContentUnavailableView.swift similarity index 93% rename from CodeEdit/Features/CodeEditUI/Views/CEContentUnavailableView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/CEContentUnavailableView.swift index 818185b808..577e826dbf 100644 --- a/CodeEdit/Features/CodeEditUI/Views/CEContentUnavailableView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/CEContentUnavailableView.swift @@ -7,13 +7,13 @@ import SwiftUI -struct CEContentUnavailableView: View { +public struct CEContentUnavailableView: View { var label: String var description: String? var systemImage: String? var actions: Actions? - init( + public init( _ label: String, description: String? = nil, systemImage: String? = nil, @@ -52,7 +52,7 @@ struct CEContentUnavailableView: View { .controlSize(.small) } - var body: some View { + public var body: some View { if #available(macOS 14, *) { contentUnavailableView .buttonStyle(.accessoryBarAction) diff --git a/CodeEdit/Features/CodeEditUI/Views/CEOutlineGroup.swift b/CodeEditModules/Sources/CodeEditUI/Views/CEOutlineGroup.swift similarity index 93% rename from CodeEdit/Features/CodeEditUI/Views/CEOutlineGroup.swift rename to CodeEditModules/Sources/CodeEditUI/Views/CEOutlineGroup.swift index 9715ffb4a5..13e4673052 100644 --- a/CodeEdit/Features/CodeEditUI/Views/CEOutlineGroup.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/CEOutlineGroup.swift @@ -9,7 +9,7 @@ import SwiftUI // This view replaces OutlineGroup, which lacks support for controlling the expanded state. -struct CEOutlineGroup: View where DataElement: Identifiable, ID: Hashable, Leaf: View { +public struct CEOutlineGroup: View where DataElement: Identifiable, ID: Hashable, Leaf: View { let root: DataElement var expandedIds: Binding<[ID: Bool]>? @State var expanded: Bool = false @@ -44,7 +44,7 @@ struct CEOutlineGroup: View where DataElement: Identifiab .tag(root[keyPath: idKeyPath]) } - var body: some View { + public var body: some View { switch root[keyPath: childrenKeyPath] { case .none: itemView diff --git a/CodeEdit/Features/CodeEditUI/Views/Divided.swift b/CodeEditModules/Sources/CodeEditUI/Views/Divided.swift similarity index 82% rename from CodeEdit/Features/CodeEditUI/Views/Divided.swift rename to CodeEditModules/Sources/CodeEditUI/Views/Divided.swift index 7a64f5a33c..b42e405fac 100644 --- a/CodeEdit/Features/CodeEditUI/Views/Divided.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/Divided.swift @@ -7,14 +7,14 @@ import SwiftUI -struct Divided: View { +public struct Divided: View { var content: Content - init(@ViewBuilder content: () -> Content) { + public init(@ViewBuilder content: () -> Content) { self.content = content() } - var body: some View { + public var body: some View { _VariadicView.Tree(DividedLayout()) { content } diff --git a/CodeEdit/Features/CodeEditUI/Views/EffectView.swift b/CodeEditModules/Sources/CodeEditUI/Views/EffectView.swift similarity index 88% rename from CodeEdit/Features/CodeEditUI/Views/EffectView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/EffectView.swift index f9a1e6eb04..71f8e80cfb 100644 --- a/CodeEdit/Features/CodeEditUI/Views/EffectView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/EffectView.swift @@ -13,7 +13,7 @@ import SwiftUI /// ```swift /// EffectView(material: .headerView, blendingMode: .withinWindow) /// ``` -struct EffectView: NSViewRepresentable { +public struct EffectView: NSViewRepresentable { private let material: NSVisualEffectView.Material private let blendingMode: NSVisualEffectView.BlendingMode private let emphasized: Bool @@ -32,7 +32,7 @@ struct EffectView: NSViewRepresentable { /// - material: The material to use. Defaults to `.headerView`. /// - blendingMode: The blending mode to use. Defaults to `.withinWindow`. /// - emphasized:A Boolean value indicating whether to emphasize the look of the material. Defaults to `false`. - init( + public init( _ material: NSVisualEffectView.Material = .headerView, blendingMode: NSVisualEffectView.BlendingMode = .withinWindow, emphasized: Bool = false @@ -42,7 +42,7 @@ struct EffectView: NSViewRepresentable { self.emphasized = emphasized } - func makeNSView(context: Context) -> NSVisualEffectView { + public func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.material = material view.blendingMode = blendingMode @@ -51,7 +51,7 @@ struct EffectView: NSViewRepresentable { return view } - func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + public func updateNSView(_ nsView: NSVisualEffectView, context: Context) { nsView.material = material nsView.blendingMode = blendingMode } @@ -62,7 +62,7 @@ struct EffectView: NSViewRepresentable { /// - Parameter condition: The condition of when to apply the background. Defaults to `true`. /// - Returns: A View @ViewBuilder - static func selectionBackground(_ condition: Bool = true) -> some View { + public static func selectionBackground(_ condition: Bool = true) -> some View { if condition { EffectView(.selection, blendingMode: .withinWindow, emphasized: true) } else { diff --git a/CodeEdit/Features/CodeEditUI/Views/ErrorDescriptionLabel.swift b/CodeEditModules/Sources/CodeEditUI/Views/ErrorDescriptionLabel.swift similarity index 84% rename from CodeEdit/Features/CodeEditUI/Views/ErrorDescriptionLabel.swift rename to CodeEditModules/Sources/CodeEditUI/Views/ErrorDescriptionLabel.swift index 9a64190da8..ee9ab2f6f5 100644 --- a/CodeEdit/Features/CodeEditUI/Views/ErrorDescriptionLabel.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/ErrorDescriptionLabel.swift @@ -7,10 +7,14 @@ import SwiftUI -struct ErrorDescriptionLabel: View { +public struct ErrorDescriptionLabel: View { let error: Error - var body: some View { + public init(error: Error) { + self.error = error + } + + public var body: some View { VStack(alignment: .leading) { if let error = error as? LocalizedError { if let description = error.errorDescription { diff --git a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift b/CodeEditModules/Sources/CodeEditUI/Views/FeatureIcon.swift similarity index 95% rename from CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift rename to CodeEditModules/Sources/CodeEditUI/Views/FeatureIcon.swift index 33d0ae09cf..b6c41f9781 100644 --- a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/FeatureIcon.swift @@ -8,12 +8,12 @@ import SwiftUI import CodeEditSymbols -struct FeatureIcon: View { +public struct FeatureIcon: View { private let content: IconContent private let color: Color? private let size: CGFloat - init( + public init( symbol: String, color: Color? = nil, size: CGFloat? = nil @@ -23,7 +23,7 @@ struct FeatureIcon: View { self.size = size ?? 20 } - init( + public init( text: String, textColor: Color? = nil, color: Color? = nil, @@ -34,7 +34,7 @@ struct FeatureIcon: View { self.size = size ?? 20 } - init( + public init( image: Image, size: CGFloat? = nil ) { @@ -51,7 +51,7 @@ struct FeatureIcon: View { } } - var body: some View { + public var body: some View { RoundedRectangle(cornerRadius: size / 4, style: .continuous) .fill(background) .overlay { diff --git a/CodeEdit/Features/CodeEditUI/Views/GlassEffectView.swift b/CodeEditModules/Sources/CodeEditUI/Views/GlassEffectView.swift similarity index 72% rename from CodeEdit/Features/CodeEditUI/Views/GlassEffectView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/GlassEffectView.swift index 8532ef7fa7..d24c740c88 100644 --- a/CodeEdit/Features/CodeEditUI/Views/GlassEffectView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/GlassEffectView.swift @@ -8,14 +8,14 @@ import SwiftUI import AppKit -struct GlassEffectView: NSViewRepresentable { +public struct GlassEffectView: NSViewRepresentable { var tintColor: NSColor? - init(tintColor: NSColor? = nil) { + public init(tintColor: NSColor? = nil) { self.tintColor = tintColor } - func makeNSView(context: Context) -> NSView { + public func makeNSView(context: Context) -> NSView { #if compiler(>=6.2) if #available(macOS 26, *) { let view = NSGlassEffectView() @@ -27,7 +27,7 @@ struct GlassEffectView: NSViewRepresentable { return NSView() } - func updateNSView(_ nsView: NSView, context: Context) { + public func updateNSView(_ nsView: NSView, context: Context) { #if compiler(>=6.2) if #available(macOS 26, *), let view = nsView as? NSGlassEffectView { view.tintColor = tintColor diff --git a/CodeEdit/Features/CodeEditUI/Views/HelpButton.swift b/CodeEditModules/Sources/CodeEditUI/Views/HelpButton.swift similarity index 90% rename from CodeEdit/Features/CodeEditUI/Views/HelpButton.swift rename to CodeEditModules/Sources/CodeEditUI/Views/HelpButton.swift index f46dea8fed..a2015ad12f 100644 --- a/CodeEdit/Features/CodeEditUI/Views/HelpButton.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/HelpButton.swift @@ -8,17 +8,17 @@ import SwiftUI /// A Button representing a system Help button displaying a question mark symbol. -struct HelpButton: View { +public struct HelpButton: View { private var action: () -> Void /// Initializes the ``HelpButton`` with an action closure /// - Parameter action: A closure that gets called once the button is pressed. - init(action: @escaping () -> Void) { + public init(action: @escaping () -> Void) { self.action = action } - var body: some View { + public var body: some View { Button(action: action, label: { ZStack { Circle() diff --git a/CodeEdit/Features/CodeEditUI/Views/InstantPopoverModifier.swift b/CodeEditModules/Sources/CodeEditUI/Views/InstantPopoverModifier.swift similarity index 88% rename from CodeEdit/Features/CodeEditUI/Views/InstantPopoverModifier.swift rename to CodeEditModules/Sources/CodeEditUI/Views/InstantPopoverModifier.swift index 037f7a701d..12dabdcdbc 100644 --- a/CodeEdit/Features/CodeEditUI/Views/InstantPopoverModifier.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/InstantPopoverModifier.swift @@ -15,7 +15,7 @@ struct InstantPopoverModifier: ViewModifier { let arrowEdge: Edge let popoverContent: PopoverContent - func body(content: Content) -> some View { + public func body(content: Content) -> some View { content .background( PopoverPresenter( @@ -35,9 +35,9 @@ struct PopoverPresenter: NSViewRepresentable { let arrowEdge: Edge let contentView: ContentView - func makeNSView(context: Context) -> NSView { NSView() } + public func makeNSView(context: Context) -> NSView { NSView() } - func updateNSView(_ nsView: NSView, context: Context) { + public func updateNSView(_ nsView: NSView, context: Context) { if isPresented && context.coordinator.popover == nil { let popover = NSPopover() popover.animates = false @@ -64,10 +64,11 @@ struct PopoverPresenter: NSViewRepresentable { } } - func makeCoordinator() -> Coordinator { + public func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented) } + @MainActor class Coordinator: NSObject, NSPopoverDelegate { @Binding var isPresented: Bool var popover: NSPopover? @@ -84,9 +85,10 @@ struct PopoverPresenter: NSViewRepresentable { object: window, queue: .main ) { [weak self] _ in - guard let self = self else { return } - /// The parent window is no longer focused, close the popover - DispatchQueue.main.async { + /// Delivered on the main queue, so it is safe to assume main-actor isolation. + MainActor.assumeIsolated { + guard let self = self else { return } + /// The parent window is no longer focused, close the popover self.isPresented = false self.popover?.close() } @@ -94,9 +96,7 @@ struct PopoverPresenter: NSViewRepresentable { } func popoverWillClose(_ notification: Notification) { - DispatchQueue.main.async { - self.isPresented = false - } + isPresented = false } func popoverDidClose(_ notification: Notification) { @@ -114,7 +114,7 @@ struct PopoverPresenter: NSViewRepresentable { } } -extension View { +public extension View { /// A custom view modifier that presents a popover attached to the view with no animation. /// - Warning: Views presented using this sheet must be dismissed by negating the `isPresented` binding. Using /// SwiftUI's `dismiss` will likely cause a crash. See [FB16221871](rdar://FB16221871) diff --git a/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift b/CodeEditModules/Sources/CodeEditUI/Views/KeyValueTable.swift similarity index 94% rename from CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift rename to CodeEditModules/Sources/CodeEditUI/Views/KeyValueTable.swift index 4fc7ab95d8..f57669ef3f 100644 --- a/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/KeyValueTable.swift @@ -7,10 +7,15 @@ import SwiftUI -struct KeyValueItem: Identifiable, Equatable { - let id = UUID() - let key: String - let value: String +public struct KeyValueItem: Identifiable, Equatable { + public let id = UUID() + public let key: String + public let value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } } private struct NewListTableItemView: View { @@ -27,7 +32,7 @@ private struct NewListTableItemView: View { let headerView: HeaderView? var completion: (String, String) -> Void - init( + public init( key: String? = nil, value: String? = nil, _ keyColumnName: String, @@ -47,7 +52,7 @@ private struct NewListTableItemView: View { self.completion = completion } - var body: some View { + public var body: some View { VStack(spacing: 0) { Form { Section { @@ -102,7 +107,7 @@ private struct NewListTableItemView: View { } } -struct KeyValueTable: View { +public struct KeyValueTable: View { @Binding var items: [String: String] let validKeys: [String] @@ -116,7 +121,7 @@ struct KeyValueTable: View { @State private var selection: Set = [] @State private var tableItems: [KeyValueItem] = [] - init( + public init( items: Binding<[String: String]>, validKeys: [String] = [], keyColumnName: String, @@ -134,7 +139,7 @@ struct KeyValueTable: View { self.actionBarTrailing = actionBarTrailing } - var body: some View { + public var body: some View { Table(tableItems, selection: $selection) { TableColumn(keyColumnName) { item in Text(item.key) diff --git a/CodeEdit/Features/OpenQuickly/Views/NSTableViewWrapper.swift b/CodeEditModules/Sources/CodeEditUI/Views/NSTableViewWrapper.swift similarity index 96% rename from CodeEdit/Features/OpenQuickly/Views/NSTableViewWrapper.swift rename to CodeEditModules/Sources/CodeEditUI/Views/NSTableViewWrapper.swift index 3be6aeb6b1..564bf4d1b7 100644 --- a/CodeEdit/Features/OpenQuickly/Views/NSTableViewWrapper.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/NSTableViewWrapper.swift @@ -132,7 +132,9 @@ struct NSTableViewWrapper: NSViewR func tableViewSelectionDidChange(_ notification: Notification) { if let view = notification.object as? NSTableView { - let newSelection = parent.data[safe: view.selectedRow] + let newSelection = parent.data.indices.contains(view.selectedRow) + ? parent.data[view.selectedRow] + : nil if newSelection != parent.selection { parent.selection = newSelection } diff --git a/CodeEdit/Features/NavigatorArea/Views/NavigatorFilterView.swift b/CodeEditModules/Sources/CodeEditUI/Views/NavigatorFilterView.swift similarity index 98% rename from CodeEdit/Features/NavigatorArea/Views/NavigatorFilterView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/NavigatorFilterView.swift index 4fb6721429..c097c27007 100644 --- a/CodeEdit/Features/NavigatorArea/Views/NavigatorFilterView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/NavigatorFilterView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct NavigatorFilterView< +public struct NavigatorFilterView< MenuContents: View, LeadingAccessories: View, TrailingAccessories: View @@ -40,7 +40,7 @@ struct NavigatorFilterView< } } - init( + public init( text: Binding, hasValue: (() -> Bool)? = nil, @ViewBuilder menu: () -> MenuContents, @@ -54,7 +54,7 @@ struct NavigatorFilterView< self.trailingAccessories = trailingAccessories() } - var body: some View { + public var body: some View { VStack(spacing: 0) { Divider() HStack(spacing: 5) { diff --git a/CodeEdit/Features/CodeEditUI/Views/PaneTextField.swift b/CodeEditModules/Sources/CodeEditUI/Views/PaneTextField.swift similarity index 96% rename from CodeEdit/Features/CodeEditUI/Views/PaneTextField.swift rename to CodeEditModules/Sources/CodeEditUI/Views/PaneTextField.swift index 78174452ce..55cdcceeaf 100644 --- a/CodeEdit/Features/CodeEditUI/Views/PaneTextField.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/PaneTextField.swift @@ -8,7 +8,7 @@ import SwiftUI import Combine -struct PaneTextField: View { +public struct PaneTextField: View { @Environment(\.colorScheme) var colorScheme @@ -33,7 +33,7 @@ struct PaneTextField: View var hasValue: Bool - init( + public init( _ label: String, text: Binding, axis: Axis? = .horizontal, @@ -76,7 +76,7 @@ struct PaneTextField: View } } - var body: some View { + public var body: some View { HStack(alignment: .top, spacing: 0) { if let leading = leadingAccessories { leading diff --git a/CodeEdit/Features/CodeEditUI/Views/PanelDivider.swift b/CodeEditModules/Sources/CodeEditUI/Views/PanelDivider.swift similarity index 78% rename from CodeEdit/Features/CodeEditUI/Views/PanelDivider.swift rename to CodeEditModules/Sources/CodeEditUI/Views/PanelDivider.swift index abca2627c5..85cb9283ee 100644 --- a/CodeEdit/Features/CodeEditUI/Views/PanelDivider.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/PanelDivider.swift @@ -7,11 +7,13 @@ import SwiftUI -struct PanelDivider: View { +public struct PanelDivider: View { @Environment(\.colorScheme) private var colorScheme - var body: some View { + public init() {} + + public var body: some View { Divider() .opacity(0) .overlay( diff --git a/CodeEditModules/Sources/CodeEditUI/Views/PopoverContainer.swift b/CodeEditModules/Sources/CodeEditUI/Views/PopoverContainer.swift new file mode 100644 index 0000000000..ef46e7d8ca --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Views/PopoverContainer.swift @@ -0,0 +1,36 @@ +// +// PopoverContainer.swift +// CodeEdit +// +// Created by Khan Winter on 8/29/25. +// + +import SwiftUI + +/// Container for SwiftUI views presented in a popover. +/// On tahoe and above, adds the correct container shape. +public struct PopoverContainer: View { + let content: () -> ContentView + + public init(@ViewBuilder content: @escaping () -> ContentView) { + self.content = content + } + + public var body: some View { + let base = VStack(alignment: .leading, spacing: 0) { + content() + } + .font(.subheadline) + + return Group { + if #available(macOS 26, *) { + base + .padding(13) + .containerShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } else { + base.padding(5) + } + } + .frame(minWidth: 215) + } +} diff --git a/CodeEdit/Features/CodeEditUI/Views/PressActionsModifier.swift b/CodeEditModules/Sources/CodeEditUI/Views/PressActionsModifier.swift similarity index 82% rename from CodeEdit/Features/CodeEditUI/Views/PressActionsModifier.swift rename to CodeEditModules/Sources/CodeEditUI/Views/PressActionsModifier.swift index 9fb32ae3c5..bc498b99a6 100644 --- a/CodeEdit/Features/CodeEditUI/Views/PressActionsModifier.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/PressActionsModifier.swift @@ -7,16 +7,16 @@ import SwiftUI -struct PressActions: ViewModifier { +public struct PressActions: ViewModifier { var onPress: () -> Void var onRelease: (() -> Void)? - init(onPress: @escaping () -> Void, onRelease: (() -> Void)? = nil) { + public init(onPress: @escaping () -> Void, onRelease: (() -> Void)? = nil) { self.onPress = onPress self.onRelease = onRelease } - func body(content: Content) -> some View { + public func body(content: Content) -> some View { content .simultaneousGesture( DragGesture(minimumDistance: 0) @@ -26,7 +26,7 @@ struct PressActions: ViewModifier { } } -extension View { +public extension View { /// A custom view modifier for press actions with callbacks for `onPress` and `onRelease`. /// - Parameters: diff --git a/CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift b/CodeEditModules/Sources/CodeEditUI/Views/QuickSearchResultLabel.swift similarity index 82% rename from CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift rename to CodeEditModules/Sources/CodeEditUI/Views/QuickSearchResultLabel.swift index 9cb60c02c4..3dba2ba6f7 100644 --- a/CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/QuickSearchResultLabel.swift @@ -10,12 +10,22 @@ import SwiftUI /// Implementation of command palette entity. While swiftui does not allow to use NSMutableAttributeStrings, /// the only way to fallback to UIKit and have NSViewRepresentable to be a bridge between UIKit and SwiftUI. /// Highlights currently entered text query -struct QuickSearchResultLabel: NSViewRepresentable { +public struct QuickSearchResultLabel: NSViewRepresentable { let labelName: String let charactersToHighlight: [NSRange] let maximumNumberOfLines: Int = 1 var nsLabelName: NSAttributedString? + public init( + labelName: String, + charactersToHighlight: [NSRange], + nsLabelName: NSAttributedString? = nil + ) { + self.labelName = labelName + self.charactersToHighlight = charactersToHighlight + self.nsLabelName = nsLabelName + } + public func makeNSView(context: Context) -> some NSTextField { let label = NSTextField(wrappingLabelWithString: labelName) label.translatesAutoresizingMaskIntoConstraints = false @@ -41,7 +51,7 @@ struct QuickSearchResultLabel: NSViewRepresentable { return attribText } - func updateNSView(_ nsView: NSViewType, context: Context) { + public func updateNSView(_ nsView: NSViewType, context: Context) { nsView.textColor = if nsLabelName == nil && charactersToHighlight.isEmpty { .controlTextColor } else { diff --git a/CodeEdit/Features/CodeEditUI/Views/SearchField.swift b/CodeEditModules/Sources/CodeEditUI/Views/SearchField.swift similarity index 65% rename from CodeEdit/Features/CodeEditUI/Views/SearchField.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SearchField.swift index ed6c8e2c7d..376c6212b7 100644 --- a/CodeEdit/Features/CodeEditUI/Views/SearchField.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SearchField.swift @@ -7,38 +7,38 @@ import SwiftUI -struct SearchField: NSViewRepresentable { +public struct SearchField: NSViewRepresentable { @Binding var text: String var placeholder: String - init(_ placeholder: String, text: Binding) { + public init(_ placeholder: String, text: Binding) { self.placeholder = placeholder self._text = text } - func makeNSView(context: Context) -> NSSearchField { + public func makeNSView(context: Context) -> NSSearchField { let searchField = NSSearchField() searchField.delegate = context.coordinator searchField.placeholderString = placeholder return searchField } - func updateNSView(_ nsView: NSSearchField, context: Context) { + public func updateNSView(_ nsView: NSSearchField, context: Context) { nsView.stringValue = text } - func makeCoordinator() -> Coordinator { + public func makeCoordinator() -> Coordinator { Coordinator(self) } - class Coordinator: NSObject, NSSearchFieldDelegate { + public class Coordinator: NSObject, NSSearchFieldDelegate { var parent: SearchField init(_ parent: SearchField) { self.parent = parent } - func controlTextDidChange(_ obj: Notification) { + public func controlTextDidChange(_ obj: Notification) { if let searchField = obj.object as? NSSearchField { parent.text = searchField.stringValue } diff --git a/CodeEdit/Features/CodeEditUI/Views/SearchPanel.swift b/CodeEditModules/Sources/CodeEditUI/Views/SearchPanel.swift similarity index 74% rename from CodeEdit/Features/CodeEditUI/Views/SearchPanel.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SearchPanel.swift index b0b6269d95..29a800fedb 100644 --- a/CodeEdit/Features/CodeEditUI/Views/SearchPanel.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SearchPanel.swift @@ -7,8 +7,8 @@ import Cocoa -final class SearchPanel: NSPanel, NSWindowDelegate { - init() { +public final class SearchPanel: NSPanel, NSWindowDelegate { + public init() { super.init( contentRect: NSRect(x: 0, y: 0, width: 500, height: 48), styleMask: [.fullSizeContentView, .titled, .resizable], @@ -20,13 +20,13 @@ final class SearchPanel: NSPanel, NSWindowDelegate { self.isMovableByWindowBackground = true } - override func standardWindowButton(_ button: NSWindow.ButtonType) -> NSButton? { + override public func standardWindowButton(_ button: NSWindow.ButtonType) -> NSButton? { let button = super.standardWindowButton(button) button?.isHidden = true return button } - func windowDidResignKey(_ notification: Notification) { + public func windowDidResignKey(_ notification: Notification) { if let panel = notification.object as? SearchPanel { panel.close() } diff --git a/CodeEdit/Features/CodeEditUI/Views/SearchPanelView.swift b/CodeEditModules/Sources/CodeEditUI/Views/SearchPanelView.swift similarity index 97% rename from CodeEdit/Features/CodeEditUI/Views/SearchPanelView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SearchPanelView.swift index 973a849acd..0255a72e1b 100644 --- a/CodeEdit/Features/CodeEditUI/Views/SearchPanelView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SearchPanelView.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI -struct SearchPanelView: View { +public struct SearchPanelView: View { @ViewBuilder let rowViewBuilder: ((Option) -> RowView) @ViewBuilder let previewViewBuilder: ((Option) -> PreviewView)? @@ -26,7 +26,7 @@ struct SearchPanelView, @@ -51,7 +51,7 @@ struct SearchPanelView, options: [String], prominent: Bool = false @@ -30,7 +30,7 @@ struct SegmentedControl: View { self.prominent = prominent } - var body: some View { + public var body: some View { HStack(spacing: 4) { ForEach(options.indices, id: \.self) { index in SegmentedControlItem( @@ -48,7 +48,7 @@ struct SegmentedControl: View { } } -struct SegmentedControlItem: View { +public struct SegmentedControlItem: View { private let color: Color = Color(nsColor: .selectedControlColor) let label: String let active: Bool @@ -65,7 +65,7 @@ struct SegmentedControlItem: View { @State var isPressing: Bool = false - var body: some View { + public var body: some View { Text(label) .font(.subheadline) .foregroundColor(textColor) diff --git a/CodeEdit/Features/SplitView/Model/CodeEditDividerStyle.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/CodeEditDividerStyle.swift similarity index 60% rename from CodeEdit/Features/SplitView/Model/CodeEditDividerStyle.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SplitView/CodeEditDividerStyle.swift index 1f41340b6b..bab5b94c05 100644 --- a/CodeEdit/Features/SplitView/Model/CodeEditDividerStyle.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/CodeEditDividerStyle.swift @@ -1,22 +1,17 @@ // // CodeEditDividerStyle.swift -// CodeEdit +// CodeEditUI // // Created by Khan Winter on 5/30/25. // import AppKit -/// The style of divider used by ``SplitView``. -/// -/// To add a new style, add another case to this enum and fill in the ``customColor`` and ``customThickness`` -/// variables. When passed to ``SplitView``, the custom styles will be used instead of the default styles. Leave -/// values as `nil` to use default styles. -enum CodeEditDividerStyle: Equatable { +public enum CodeEditDividerStyle: Equatable, Sendable { case system(NSSplitView.DividerStyle) case editorDivider - var customColor: NSColor? { + public var customColor: NSColor? { switch self { case .system: return nil @@ -31,7 +26,7 @@ enum CodeEditDividerStyle: Equatable { } } - var customThickness: CGFloat? { + public var customThickness: CGFloat? { switch self { case .system: return nil diff --git a/CodeEditModules/Sources/CodeEditUI/Views/SplitView/Environment+ContentInsets.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/Environment+ContentInsets.swift new file mode 100644 index 0000000000..84973c792c --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/Environment+ContentInsets.swift @@ -0,0 +1,28 @@ +// +// Environment+ContentInsets.swift +// CodeEditUI +// +// Created by Wouter Hennen on 24/02/2023. +// + +import SwiftUI + +public struct EdgeInsetsEnvironmentKey: EnvironmentKey { + nonisolated(unsafe) public static var defaultValue: EdgeInsets = + EdgeInsets(top: 1, leading: 0, bottom: 0, trailing: 0) +} + +public extension EnvironmentValues { + /// The insets a container applies around its content, e.g. to keep split view content clear of the toolbar. + var edgeInsets: EdgeInsetsEnvironmentKey.Value { + get { self[EdgeInsetsEnvironmentKey.self] } + set { self[EdgeInsetsEnvironmentKey.self] = newValue } + } +} + +public extension EdgeInsets { + /// The same insets converted to an AppKit `NSEdgeInsets`, mapping leading/trailing to left/right. + var nsEdgeInsets: NSEdgeInsets { + .init(top: top, left: leading, bottom: bottom, right: trailing) + } +} diff --git a/CodeEdit/Features/SplitView/Views/SplitView.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitView.swift similarity index 78% rename from CodeEdit/Features/SplitView/Views/SplitView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitView.swift index 9fe9463c98..e052dccbc0 100644 --- a/CodeEdit/Features/SplitView/Views/SplitView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitView.swift @@ -1,18 +1,18 @@ // // SplitView.swift -// CodeEdit +// CodeEditUI // // Created by Wouter Hennen on 22/02/2023. // import SwiftUI -struct SplitView: View { +public struct SplitView: View { var axis: Axis var dividerStyle: CodeEditDividerStyle var content: Content - init(axis: Axis, dividerStyle: CodeEditDividerStyle = .system(.thin), @ViewBuilder content: () -> Content) { + public init(axis: Axis, dividerStyle: CodeEditDividerStyle = .system(.thin), @ViewBuilder content: () -> Content) { self.axis = axis self.dividerStyle = dividerStyle self.content = content() @@ -20,7 +20,7 @@ struct SplitView: View { @State private var viewController: () -> SplitViewController? = { nil } - var body: some View { + public var body: some View { VStack { content.variadic { children in SplitViewControllerView( diff --git a/CodeEdit/Features/SplitView/Views/SplitViewControllerView.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewControllerView.swift similarity index 77% rename from CodeEdit/Features/SplitView/Views/SplitViewControllerView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewControllerView.swift index d0094d3aad..e4db9728ce 100644 --- a/CodeEdit/Features/SplitView/Views/SplitViewControllerView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewControllerView.swift @@ -1,27 +1,27 @@ // // SplitViewControllerView.swift -// CodeEdit +// CodeEditUI // // Created by Wouter Hennen on 20/02/2023. // import SwiftUI -struct SplitViewControllerView: NSViewControllerRepresentable { +public struct SplitViewControllerView: NSViewControllerRepresentable { var axis: Axis var dividerStyle: CodeEditDividerStyle var children: _VariadicView.Children @Binding var viewController: () -> SplitViewController? - func makeNSViewController(context: Context) -> SplitViewController { + public func makeNSViewController(context: Context) -> SplitViewController { let controller = SplitViewController(axis: axis, parentView: self) { controller in updateItems(controller: controller) } return controller } - func updateNSViewController(_ controller: SplitViewController, context: Context) { + public func updateNSViewController(_ controller: SplitViewController, context: Context) { updateItems(controller: controller) controller.setDividerStyle(dividerStyle) } @@ -48,11 +48,8 @@ struct SplitViewControllerView: NSViewControllerRepresentable { let numerator = splitView.isVertical ? splitView.frame.width : splitView.frame.height for idx in 0.. Void)? @@ -125,12 +122,12 @@ final class SplitViewController: NSSplitViewController { fatalError("init(coder:) has not been implemented") } - override func loadView() { + override public func loadView() { splitView = CustomSplitView() super.loadView() } - override func viewDidLoad() { + override public func viewDidLoad() { super.viewDidLoad() splitView.isVertical = axis != .vertical setUpItems?(self) @@ -141,21 +138,22 @@ final class SplitViewController: NSSplitViewController { } } - override func splitView(_ splitView: NSSplitView, shouldHideDividerAt dividerIndex: Int) -> Bool { - // For some reason, AppKit _really_ wants to hide dividers when there's only one item (and no dividers) - // so we do this check for them. + override public func splitView( + _ splitView: NSSplitView, + shouldHideDividerAt dividerIndex: Int + ) -> Bool { guard items.count > 1 else { return false } return super.splitView(splitView, shouldHideDividerAt: dividerIndex) } - func setDividerStyle(_ dividerStyle: CodeEditDividerStyle) { + public func setDividerStyle(_ dividerStyle: CodeEditDividerStyle) { guard let splitView = splitView as? CustomSplitView else { return } splitView.customDividerStyle = dividerStyle } - func collapse(for id: AnyHashable, enabled: Bool) { + public func collapse(for id: AnyHashable, enabled: Bool) { items.first { $0.id == id }?.item.animator().isCollapsed = enabled } } diff --git a/CodeEdit/Features/SplitView/Model/SplitViewItem.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewItem.swift similarity index 64% rename from CodeEdit/Features/SplitView/Model/SplitViewItem.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewItem.swift index 9f8521e808..c7ed9df838 100644 --- a/CodeEdit/Features/SplitView/Model/SplitViewItem.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewItem.swift @@ -1,6 +1,6 @@ // // SplitViewItem.swift -// CodeEdit +// CodeEditUI // // Created by Wouter Hennen on 05/03/2023. // @@ -8,10 +8,11 @@ import SwiftUI import Combine -class SplitViewItem: ObservableObject { +@MainActor +public class SplitViewItem: ObservableObject { - var id: AnyHashable - var item: NSSplitViewItem + public var id: AnyHashable + public var item: NSSplitViewItem var collapsed: Binding @@ -19,14 +20,13 @@ class SplitViewItem: ObservableObject { var observers: [NSKeyValueObservation] = [] - init(child: _VariadicView.Children.Element) { + public init(child: _VariadicView.Children.Element) { self.id = child.id self.item = NSSplitViewItem(viewController: NSHostingController(rootView: child)) self.collapsed = child[SplitViewItemCollapsedViewTraitKey.self] self.item.canCollapse = child[SplitViewItemCanCollapseViewTraitKey.self] self.item.isCollapsed = self.collapsed.wrappedValue self.item.holdingPriority = child[SplitViewHoldingPriorityTraitKey.self] - // Skip the initial observation via a dispatch to avoid a "updating during view update" error DispatchQueue.main.async { self.observers = self.createObservers() } @@ -35,15 +35,20 @@ class SplitViewItem: ObservableObject { private func createObservers() -> [NSKeyValueObservation] { [ item.observe(\.isCollapsed) { [weak self] item, _ in - self?.collapsed.wrappedValue = item.isCollapsed + // Read the value out here so only a `Bool` crosses into the main actor + // region — `item` is non-Sendable and cannot be sent. + let isCollapsed = item.isCollapsed + // AppKit mutates `isCollapsed` on the main thread, so this KVO callback is + // always delivered there. Assert that rather than hopping asynchronously — + // a `Task { @MainActor }` would delay the binding update by a runloop turn. + MainActor.assumeIsolated { + self?.collapsed.wrappedValue = isCollapsed + } } ] } - /// Updates a SplitViewItem. - /// This will fetch updated binding values and update them if needed. - /// - Parameter child: the view corresponding to the SplitViewItem. - func update(child: _VariadicView.Children.Element) { + public func update(child: _VariadicView.Children.Element) { self.item.canCollapse = child[SplitViewItemCanCollapseViewTraitKey.self] let canAnimate = child[SplitViewItemCanAnimateViewTraitKey.self] DispatchQueue.main.async { diff --git a/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewModifiers.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewModifiers.swift new file mode 100644 index 0000000000..2c9aff7dfe --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewModifiers.swift @@ -0,0 +1,57 @@ +// +// SplitViewModifiers.swift +// CodeEditUI +// +// Created by Wouter Hennen on 05/03/2023. +// + +import SwiftUI + +public struct SplitViewControllerLayoutValueKey: _ViewTraitKey { + nonisolated(unsafe) public static var defaultValue: () -> SplitViewController? = { nil } +} + +public struct SplitViewItemCollapsedViewTraitKey: _ViewTraitKey { + nonisolated(unsafe) public static var defaultValue: Binding = .constant(false) +} + +public struct SplitViewItemCanCollapseViewTraitKey: _ViewTraitKey { + nonisolated(unsafe) public static var defaultValue: Bool = false +} + +public struct SplitViewHoldingPriorityTraitKey: _ViewTraitKey { + nonisolated(unsafe) public static var defaultValue: NSLayoutConstraint.Priority = .defaultLow +} + +public struct SplitViewItemCanAnimateViewTraitKey: _ViewTraitKey { + public static var defaultValue: Bool { true } +} + +public extension View { + /// Binds the collapsed state of this split view item, so it can be collapsed and observed programmatically. + func collapsed(_ value: Binding) -> some View { + self + ._trait(SplitViewItemCollapsedViewTraitKey.self, .init { + value.wrappedValue + } set: { + value.wrappedValue = $0 + }) + } + + /// Allows the user to collapse this split view item by dragging its divider to the edge. + func collapsable() -> some View { + self + ._trait(SplitViewItemCanCollapseViewTraitKey.self, true) + } + + /// Sets the holding priority of this split view item, deciding which item resizes first when space changes. + func holdingPriority(_ priority: NSLayoutConstraint.Priority) -> some View { + self + ._trait(SplitViewHoldingPriorityTraitKey.self, priority) + } + + /// Controls whether collapsing or expanding this split view item is animated. + func splitViewCanAnimate(_ enabled: Binding) -> some View { + self._trait(SplitViewItemCanAnimateViewTraitKey.self, enabled.wrappedValue) + } +} diff --git a/CodeEdit/Features/SplitView/Views/SplitViewReader.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewReader.swift similarity index 51% rename from CodeEdit/Features/SplitView/Views/SplitViewReader.swift rename to CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewReader.swift index e909edc176..ff85ec7a59 100644 --- a/CodeEdit/Features/SplitView/Views/SplitViewReader.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/SplitViewReader.swift @@ -1,23 +1,27 @@ // // SplitViewReader.swift -// CodeEdit +// CodeEditUI // // Created by Wouter Hennen on 05/03/2023. // import SwiftUI -struct SplitViewReader: View { +public struct SplitViewReader: View { @ViewBuilder var content: (SplitViewProxy) -> Content + public init(@ViewBuilder content: @escaping (SplitViewProxy) -> Content) { + self.content = content + } + @State private var viewController: () -> SplitViewController? = { nil } private var proxy: SplitViewProxy { .init(viewController: viewController) } - var body: some View { + public var body: some View { content(proxy) .variadic { children in ForEach(children, id: \.id) { child in @@ -30,28 +34,25 @@ struct SplitViewReader: View { } } -struct SplitViewProxy { +/// A handle to a `SplitView`, vended by ``SplitViewReader``, for imperatively moving dividers and +/// collapsing items. +public struct SplitViewProxy { private var viewController: () -> SplitViewController? - fileprivate init(viewController: @escaping () -> SplitViewController?) { + /// Creates a proxy that resolves the underlying controller lazily, so it works before the split view exists. + public init(viewController: @escaping () -> SplitViewController?) { self.viewController = viewController } - /// Set the position of a divider in a splitview. - /// - Parameters: - /// - index: index of the divider. The mostleft / top divider has index 0. - /// - position: position to place the divider. This is a position inside the views width / height. - /// For example, if the splitview has a width of 500, setting the position to 250 - /// will put the divider in the middle of the splitview. - func setPosition(of index: Int, position: CGFloat) { + /// Moves the divider at `index` to the given position, in points from the split view's leading/top edge. + @MainActor + public func setPosition(of index: Int, position: CGFloat) { viewController()?.splitView.setPosition(position, ofDividerAt: index) } - /// Collapse a view of the splitview. - /// - Parameters: - /// - id: ID of the view - /// - enabled: true for collapse. - func collapseView(with id: AnyHashable, _ enabled: Bool) { + /// Collapses or expands the split view item identified by `id`, animating the change. + @MainActor + public func collapseView(with id: AnyHashable, _ enabled: Bool) { viewController()?.collapse(for: id, enabled: enabled) } } diff --git a/CodeEditModules/Sources/CodeEditUI/Views/SplitView/Variadic.swift b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/Variadic.swift new file mode 100644 index 0000000000..119c45cfb2 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Views/SplitView/Variadic.swift @@ -0,0 +1,24 @@ +// +// Variadic.swift +// CodeEditUI +// +// Created by Wouter Hennen on 05/03/2023. +// + +import SwiftUI + +public struct Helper: _VariadicView_UnaryViewRoot { + var _body: (_VariadicView.Children) -> Result + + public func body(children: _VariadicView.Children) -> some View { + _body(children) + } +} + +public extension View { + /// Exposes this view's resolved children so `process` can rebuild the hierarchy from them, e.g. to + /// read per-child view traits. Used by ``SplitView`` to turn its content into individual split items. + func variadic(@ViewBuilder process: @escaping (_VariadicView.Children) -> R) -> some View { + _VariadicView.Tree(Helper(_body: process), content: { self }) + } +} diff --git a/CodeEdit/Features/CodeEditUI/Views/TrackableScrollView.swift b/CodeEditModules/Sources/CodeEditUI/Views/TrackableScrollView.swift similarity index 95% rename from CodeEdit/Features/CodeEditUI/Views/TrackableScrollView.swift rename to CodeEditModules/Sources/CodeEditUI/Views/TrackableScrollView.swift index 32a1a7acbb..fd49c8c62c 100644 --- a/CodeEdit/Features/CodeEditUI/Views/TrackableScrollView.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/TrackableScrollView.swift @@ -13,21 +13,21 @@ import SwiftUI private struct ScrollViewOffsetPreferenceKey: PreferenceKey { typealias Value = [CGFloat] - static var defaultValue: [CGFloat] = [0] + static let defaultValue: [CGFloat] = [0] static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) { value.append(contentsOf: nextValue()) } } -struct TrackableScrollView: View where Content: View { +public struct TrackableScrollView: View where Content: View { let axes: Axis.Set let showIndicators: Bool @Binding var contentOffset: CGFloat @Binding var contentTrailingOffset: CGFloat? let content: Content - init( + public init( _ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding, @@ -40,7 +40,7 @@ struct TrackableScrollView: View where Content: View { self.content = content() } - init( + public init( _ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding, @@ -54,7 +54,7 @@ struct TrackableScrollView: View where Content: View { self.content = content() } - var body: some View { + public var body: some View { GeometryReader { outsideProxy in ScrollView(self.axes, showsIndicators: self.showIndicators) { ZStack(alignment: self.axes == .vertical ? .top : .leading) { diff --git a/CodeEdit/Features/Settings/Pages/GeneralSettings/View+actionBar.swift b/CodeEditModules/Sources/CodeEditUI/Views/View+actionBar.swift similarity index 79% rename from CodeEdit/Features/Settings/Pages/GeneralSettings/View+actionBar.swift rename to CodeEditModules/Sources/CodeEditUI/Views/View+actionBar.swift index 2da0f779af..198e0a00ee 100644 --- a/CodeEdit/Features/Settings/Pages/GeneralSettings/View+actionBar.swift +++ b/CodeEditModules/Sources/CodeEditUI/Views/View+actionBar.swift @@ -7,7 +7,9 @@ import SwiftUI -extension View { +public extension View { + /// Overlays a 24pt bar along the view's bottom edge, showing `content` as small icon buttons. + /// Used under lists for add/remove-style controls, like the +/- bar in settings tables. func actionBar(@ViewBuilder content: () -> Content) -> some View { self .padding(.bottom, 24) diff --git a/CodeEditModules/Sources/CodeEditUI/Views/View+if.swift b/CodeEditModules/Sources/CodeEditUI/Views/View+if.swift new file mode 100644 index 0000000000..655f5152b0 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Views/View+if.swift @@ -0,0 +1,47 @@ +// +// View+if.swift +// CodeEditUI +// +// Created by Khan Winter on 8/28/25. +// + +import SwiftUI + +public extension View { + /// Applies `transform` to the view only when `condition` is true, returning the view unchanged otherwise. + /// Note the condition changing swaps the view's identity, resetting any state inside. + @ViewBuilder + func `if`(_ condition: Bool, @ViewBuilder transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } + + /// Applies `transform` to the view when `condition` is true, otherwise applies `elseTransform`. + /// Note the condition changing swaps the view's identity, resetting any state inside. + @ViewBuilder + func `if`( + _ condition: Bool, + @ViewBuilder transform: (Self) -> Content, + @ViewBuilder else elseTransform: (Self) -> ElseContent + ) -> some View { + if condition { + transform(self) + } else { + elseTransform(self) + } + } +} + +public extension Bool { + /// Whether the app is running on macOS 26 (Tahoe) or later, for gating Tahoe-specific styling. + static var tahoe: Bool { + if #available(macOS 26, *) { + return true + } else { + return false + } + } +} diff --git a/CodeEditModules/Sources/CodeEditUI/Views/ViewOffsetKey.swift b/CodeEditModules/Sources/CodeEditUI/Views/ViewOffsetKey.swift new file mode 100644 index 0000000000..1bf60e63cd --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/Views/ViewOffsetKey.swift @@ -0,0 +1,17 @@ +// +// ViewOffsetKey.swift +// CodeEditUI +// +// Created by Austin Condiff on 4/8/23. +// + +import SwiftUI + +/// A `PreferenceKey` that accumulates a scalar view offset, used to track scroll position. +public struct ViewOffsetKey: PreferenceKey { + public typealias Value = CGFloat + public static let defaultValue = CGFloat.zero + public static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} diff --git a/CodeEditModules/Sources/CodeEditUI/WorkspacePanelContribution.swift b/CodeEditModules/Sources/CodeEditUI/WorkspacePanelContribution.swift new file mode 100644 index 0000000000..f668a5fc25 --- /dev/null +++ b/CodeEditModules/Sources/CodeEditUI/WorkspacePanelContribution.swift @@ -0,0 +1,78 @@ +// +// WorkspacePanelContribution.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import SwiftUI + +/// One tab contributed to a workspace panel — the navigator, inspector or utility area. +/// +/// A contribution is a value, not a case: first-party features, the app shell itself and installed +/// extensions all vend these, and the panel that renders them cannot tell which is which. That is +/// deliberate — it is what lets a new contribution source arrive without the panel code changing. +/// +/// Lives in `CodeEditUI` because it needs SwiftUI and nothing else. It cannot live in +/// `CodeEditCore`, whose charter forbids UI imports. +public protocol WorkspacePanelContribution: Identifiable { + /// Stable across launches, and unique within a panel. + /// + /// Extension-provided ids must be namespaced by their provider so two extensions cannot collide. + var id: String { get } + + /// Shown in the tab's tooltip and accessibility label. + var title: String { get } + + /// An SF Symbol name for the tab. + var systemImage: String { get } + + /// The tab's content. + /// + /// Type-erased because a panel holds a heterogeneous list. Erasure happens once per tab, not per + /// row, and one tab is visible at a time. + /// + /// Main-actor isolated because every contribution vends a SwiftUI view, and `View`'s members are + /// main-actor isolated. Left nonisolated, a conformer in a Swift 6 target warns when it + /// constructs its own content view — the app target simply doesn't report it, being Swift 5. + /// Only this requirement is isolated: `id`, `title` and `systemImage` are plain values read from + /// non-isolated positions. + @MainActor var content: AnyView { get } + + /// A bar pinned below the tab's content — filters, counts, the navigator's sort controls. + /// + /// Defaults to nothing, so a contribution that has no such bar says nothing about it. + /// + /// A requirement rather than a parameter the panel is handed: it arrived upstream as a `switch` + /// over the retired tab enum, where every new tab had to remember to add its case. Vended by the + /// contribution, a tab cannot forget, and a package can supply one without the panel — which + /// cannot see the package — knowing it exists. + /// + /// Placement is the panel's business, not the tab's: pre-Tahoe it insets the tab's own content, + /// and from macOS 26 it spans the panel below the tab bar. + @MainActor var bottomView: AnyView? { get } +} + +public extension WorkspacePanelContribution { + /// Most tabs have no bottom bar. + @MainActor var bottomView: AnyView? { nil } +} + +public extension Collection where Element == any WorkspacePanelContribution { + /// The selection that should be in effect for this list, given the one currently stored. + /// + /// Keeps `current` when it still names a tab here, and otherwise falls back to the first — or to + /// `nil` when there are no tabs at all. + /// + /// A panel's tab list is not fixed: the inspector rebuilds it when a setting changes, and any + /// panel's list changes when an extension is enabled or disabled. Without this, a selection + /// pointing at a tab that has just gone away leaves the panel reading "No Selection" until the + /// user clicks something — the stored id is stale rather than absent, so nothing recovers on its + /// own. + func reconcilingSelection(_ current: String?) -> String? { + if let current, contains(where: { $0.id == current }) { + return current + } + return first?.id + } +} diff --git a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift b/CodeEditModules/Sources/ShellClient/ShellClient.swift similarity index 78% rename from CodeEdit/Utils/ShellClient/Models/ShellClient.swift rename to CodeEditModules/Sources/ShellClient/ShellClient.swift index 37dcfe513b..caab8dc32e 100644 --- a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift +++ b/CodeEditModules/Sources/ShellClient/ShellClient.swift @@ -7,20 +7,26 @@ import Combine import Foundation +import CodeEditCore /// Errors that can occur during shell operations -enum ShellClientError: Error { +public enum ShellClientError: Error { case failedToDecodeOutput case taskTerminated(code: Int) } /// Shell Client /// Run commands in shell -class ShellClient { +/// +/// `@unchecked Sendable`: the only mutable state, `cancellables`, is confined +/// by `cancellablesLock`; everything else is immutable per call. +public final class ShellClient: ShellClientProtocol, @unchecked Sendable { + public init() {} + /// Generate a process and pipe to run commands /// - Parameter args: commands to run /// - Returns: command output - func generateProcessAndPipe(_ args: [String]) -> (Process, Pipe) { + private func generateProcessAndPipe(_ args: [String]) -> (Process, Pipe) { // Run in an 'interactive' login shell. Because we're passing -c here it won't actually be // interactive but it will source the user's zshrc file as well as the zshprofile. var arguments = ["-lic"] @@ -34,14 +40,15 @@ class ShellClient { return (task, pipe) } - /// Cancellable tasks - var cancellables: [UUID: AnyCancellable] = [:] + private let cancellablesLock = NSLock() + /// Cancellable tasks. Access only while holding `cancellablesLock`. + private var cancellables: [UUID: AnyCancellable] = [:] /// Run a command /// - Parameter args: command to run /// - Returns: command output @discardableResult - func run(_ args: String...) throws -> String { + public func run(_ args: [String]) throws -> String { let (task, pipe) = generateProcessAndPipe(args) try task.run() let data = pipe.fileHandleForReading.readDataToEndOfFile() @@ -55,7 +62,7 @@ class ShellClient { /// - Parameter args: command to run /// - Returns: command output @discardableResult - func runLive(_ args: String...) -> AnyPublisher { + public func runLive(_ args: [String]) -> AnyPublisher { let subject = PassthroughSubject() let (task, pipe) = generateProcessAndPipe(args) let outputHandler = pipe.fileHandleForReading @@ -63,16 +70,20 @@ class ShellClient { // the Notification with Name: `NSFileHandleDataAvailable` outputHandler.waitForDataInBackgroundAndNotify() let id = UUID() - self.cancellables[id] = NotificationCenter + let cancellable = NotificationCenter .default .publisher(for: .NSFileHandleDataAvailable, object: outputHandler) - .sink { _ in + .sink { [weak self] _ in let data = outputHandler.availableData guard !data.isEmpty else { // if no data is available anymore // we should cancel this cancellable // and mark the subject as finished - self.cancellables.removeValue(forKey: id) + if let self { + self.cancellablesLock.withLock { + _ = self.cancellables.removeValue(forKey: id) + } + } subject.send(completion: .finished) return } @@ -84,6 +95,9 @@ class ShellClient { .forEach({ subject.send(String($0)) }) outputHandler.waitForDataInBackgroundAndNotify() } + cancellablesLock.withLock { + cancellables[id] = cancellable + } task.launch() return subject.eraseToAnyPublisher() } @@ -91,7 +105,7 @@ class ShellClient { /// Run a command with AsyncStream /// - Parameter args: command to run /// - Returns: async stream of command output - func runAsync(_ args: String...) -> AsyncThrowingStream { + public func runAsync(_ args: [String]) -> AsyncThrowingStream { let (task, pipe) = generateProcessAndPipe(args) return AsyncThrowingStream { continuation in @@ -126,10 +140,4 @@ class ShellClient { } } } - - /// Shell client - /// - Returns: description - static func live() -> ShellClient { - return ShellClient() - } } diff --git a/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift b/CodeEditModules/Tests/CELSPTests/SemanticTokenMapTests.swift similarity index 99% rename from CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift rename to CodeEditModules/Tests/CELSPTests/SemanticTokenMapTests.swift index a9ec5c5a3b..f168edf002 100644 --- a/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift +++ b/CodeEditModules/Tests/CELSPTests/SemanticTokenMapTests.swift @@ -5,10 +5,10 @@ // Created by Khan Winter on 12/14/24. // +@testable import CELSP import XCTest import CodeEditSourceEditor import LanguageServerProtocol -@testable import CodeEdit final class SemanticTokenMapTests: XCTestCase { // Ignores the line parameter and just returns a range from the char and length for testing diff --git a/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift b/CodeEditModules/Tests/CELSPTests/SemanticTokenStorageTests.swift similarity index 99% rename from CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift rename to CodeEditModules/Tests/CELSPTests/SemanticTokenStorageTests.swift index f2d0179caf..2aebe230f9 100644 --- a/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift +++ b/CodeEditModules/Tests/CELSPTests/SemanticTokenStorageTests.swift @@ -5,11 +5,11 @@ // Created by Khan Winter on 12/26/24. // +@testable import CELSP import Foundation import Testing import CodeEditSourceEditor import LanguageServerProtocol -@testable import CodeEdit // For easier comparison while setting semantic tokens extension SemanticToken: @retroactive Equatable { diff --git a/CodeEditTests/Features/Documents/Indexer/AsyncIndexingTests.swift b/CodeEditModules/Tests/CESearchTests/AsyncIndexingTests.swift similarity index 98% rename from CodeEditTests/Features/Documents/Indexer/AsyncIndexingTests.swift rename to CodeEditModules/Tests/CESearchTests/AsyncIndexingTests.swift index ffaa0030c8..ae565fa1e9 100644 --- a/CodeEditTests/Features/Documents/Indexer/AsyncIndexingTests.swift +++ b/CodeEditModules/Tests/CESearchTests/AsyncIndexingTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import CodeEdit +import CESearch final class AsyncIndexingTests: XCTestCase { func testAddDocuments() { diff --git a/CodeEditModules/Tests/CESearchTests/FindReplaceQueryTests.swift b/CodeEditModules/Tests/CESearchTests/FindReplaceQueryTests.swift new file mode 100644 index 0000000000..6982422bde --- /dev/null +++ b/CodeEditModules/Tests/CESearchTests/FindReplaceQueryTests.swift @@ -0,0 +1,89 @@ +// +// FindReplaceQueryTests.swift +// CESearchTests +// +// Created by Matthijs Eikelenboom. +// + +import XCTest +import CodeEditCore +import CESearch + +final class FindReplaceQueryBridgeTests: XCTestCase { + private var directory: URL! + private var searchState: SearchState! + + override func setUp() async throws { + directory = try FileManager.default.url( + for: .developerApplicationDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appending(path: "CodeEdit", directoryHint: .isDirectory) + .appending(path: "FindReplaceQueryBridgeTests", directoryHint: .isDirectory) + try? FileManager.default.removeItem(at: directory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + searchState = SearchState(workspaceURL: directory, eventBus: EventBus()) + + // Wait for indexing to settle before the test starts mutating state, to avoid racing + // background indexing work against the next test's setUp/tearDown on the same directory. + let startTime = Date() + let timeoutInSeconds = 2.0 + while searchState.indexStatus != .done { + try? await Task.sleep(nanoseconds: 100_000_000) + if Date().timeIntervalSince(startTime) > timeoutInSeconds { + XCTFail("TIMEOUT: Indexing took too long or did not complete.") + return + } + } + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + searchState = nil + } + + func testSearchQueryStartsEmptyOnBothSides() { + XCTAssertEqual(searchState.searchQuery, "") + XCTAssertEqual(searchState.query.searchQuery, "") + } + + func testSearchQuerySyncsToFindReplaceQuery() async { + searchState.searchQuery = "hello" + await waitUntil { self.searchState.query.searchQuery == "hello" } + XCTAssertEqual(searchState.query.searchQuery, "hello") + } + + func testFindReplaceQuerySyncsBackToSearchQuery() async { + searchState.query.searchQuery = "world" + await waitUntil { self.searchState.searchQuery == "world" } + XCTAssertEqual(searchState.searchQuery, "world") + } + + func testReplaceTextSyncsToFindReplaceQuery() async { + searchState.replaceText = "replacement" + await waitUntil { self.searchState.query.replaceText == "replacement" } + XCTAssertEqual(searchState.query.replaceText, "replacement") + } + + func testFindReplaceQueryReplaceTextSyncsBack() async { + searchState.query.replaceText = "other" + await waitUntil { self.searchState.replaceText == "other" } + XCTAssertEqual(searchState.replaceText, "other") + } + + /// Polls `condition` until it's true or 2 seconds elapse, yielding to the run loop between checks so + /// `.receive(on: RunLoop.main)`-scheduled Combine work actually gets a chance to run. + private func waitUntil(timeout: TimeInterval = 10, _ condition: @escaping () -> Bool) async { + let startTime = Date() + while !condition() { + try? await Task.sleep(nanoseconds: 20_000_000) + if Date().timeIntervalSince(startTime) > timeout { + XCTFail("TIMEOUT: Condition did not become true within \(timeout) seconds.") + return + } + } + } +} diff --git a/CodeEditTests/Features/Documents/Indexer/MemoryIndexingTests.swift b/CodeEditModules/Tests/CESearchTests/MemoryIndexingTests.swift similarity index 98% rename from CodeEditTests/Features/Documents/Indexer/MemoryIndexingTests.swift rename to CodeEditModules/Tests/CESearchTests/MemoryIndexingTests.swift index a823c4ed95..67939cea5d 100644 --- a/CodeEditTests/Features/Documents/Indexer/MemoryIndexingTests.swift +++ b/CodeEditModules/Tests/CESearchTests/MemoryIndexingTests.swift @@ -1,12 +1,12 @@ // -// MemoryIndexing.swift +// MemoryIndexingTests.swift // CodeEditTests // // Created by Tommy Ludwig on 08.12.23. // import XCTest -@testable import CodeEdit +import CESearch final class MemoryIndexingTests: XCTestCase { func testIndexFile() { diff --git a/CodeEditTests/Features/Documents/Indexer/MemorySearchTests.swift b/CodeEditModules/Tests/CESearchTests/MemorySearchTests.swift similarity index 98% rename from CodeEditTests/Features/Documents/Indexer/MemorySearchTests.swift rename to CodeEditModules/Tests/CESearchTests/MemorySearchTests.swift index 0afedcaf18..15e7d765bd 100644 --- a/CodeEditTests/Features/Documents/Indexer/MemorySearchTests.swift +++ b/CodeEditModules/Tests/CESearchTests/MemorySearchTests.swift @@ -1,12 +1,12 @@ // -// MemoryIndexSearch.swift +// MemorySearchTests.swift // CodeEditTests // // Created by Tommy Ludwig on 08.12.23. // import XCTest -@testable import CodeEdit +import CESearch final class MemoryIndexSearchTests: XCTestCase { func testIndexFileSearch() { diff --git a/CodeEditTests/Features/Documents/Indexer/TemporaryFile.swift b/CodeEditModules/Tests/CESearchTests/TemporaryFile.swift similarity index 100% rename from CodeEditTests/Features/Documents/Indexer/TemporaryFile.swift rename to CodeEditModules/Tests/CESearchTests/TemporaryFile.swift diff --git a/CodeEditModules/Tests/CESourceControlTests/CommitFormattingTests.swift b/CodeEditModules/Tests/CESourceControlTests/CommitFormattingTests.swift new file mode 100644 index 0000000000..70de789041 --- /dev/null +++ b/CodeEditModules/Tests/CESourceControlTests/CommitFormattingTests.swift @@ -0,0 +1,57 @@ +// +// CommitFormattingTests.swift +// CESourceControlTests +// +// Created by Matthijs Eikelenboom on 14/08/26. +// + +import Foundation +import XCTest +@testable import CESourceControl + +/// Covers the two formatting helpers the commit list relies on. They moved here with +/// `CommitListItemView` — `relativeStringToNow` renders a commit's age, `md5` builds the Gravatar +/// hash for its author — and these assertions are the ones they shipped with app-side. +final class CommitFormattingTests: XCTestCase { + + // MARK: - Date + relative string + + func testRelativeDateStringMinutes() throws { + let date = Date.now.addingTimeInterval(-61) + let string = date.relativeStringToNow(locale: Locale(identifier: "en_US")) + + XCTAssertEqual("1 min. ago", string) + } + + func testRelativeDateStringHours() throws { + let date = Date.now.addingTimeInterval(-3_601) + let string = date.relativeStringToNow(locale: Locale(identifier: "en_US")) + + XCTAssertEqual("1 hr. ago", string) + } + + func testRelativeDateStringDays() throws { + let date = Date.now.addingTimeInterval(-86_400) + let string = date.relativeStringToNow(locale: Locale(identifier: "en_US")) + + XCTAssertEqual("yesterday", string) + } + + // MARK: - String + MD5 + + func testMD5GenerationCaseSensitive() throws { + let testString = "CodeEdit" + let md5 = testString.md5(caseSensitive: true) + + let result = "8ba8c8fd0442f7bae4d441e2a3fda706" + XCTAssertEqual(result, md5) + } + + func testMD5Generation() throws { + let testString = "CodeEdit" + let md5 = testString.md5(caseSensitive: false) + + let result = "4cdf122ff382a2d929eddc1a63473ec1" + XCTAssertEqual(result, md5) + } +} diff --git a/CodeEditModules/Tests/CESourceControlTests/GitRefreshActionsTests.swift b/CodeEditModules/Tests/CESourceControlTests/GitRefreshActionsTests.swift new file mode 100644 index 0000000000..81e865b522 --- /dev/null +++ b/CodeEditModules/Tests/CESourceControlTests/GitRefreshActionsTests.swift @@ -0,0 +1,51 @@ +// +// GitRefreshActionsTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +@testable import CESourceControl +import XCTest + +final class GitRefreshActionsTests: XCTestCase { + private let root = "MyWorkspace" + + private typealias Action = SourceControlManager.GitRefreshAction + + private func actions(_ paths: [String]) -> Set { + SourceControlManager.gitRefreshActions(for: paths, workspaceRelativePath: root) + } + + func testNonGitChangeRefreshesChangedFiles() { + XCTAssertTrue(actions(["MyWorkspace/Sources/App.swift"]).contains(.changedFiles)) + } + + func testIndexChangeRefreshesChangedFiles() { + XCTAssertTrue(actions(["MyWorkspace/.git/index"]).contains(.changedFiles)) + } + + func testPureGitInternalChangeDoesNotRefreshChangedFiles() { + XCTAssertFalse(actions(["MyWorkspace/.git/logs/HEAD"]).contains(.changedFiles)) + } + + func testStashRefRefreshesStash() { + XCTAssertTrue(actions(["MyWorkspace/.git/refs/stash"]).contains(.stash)) + } + + func testHeadsRefreshesBranches() { + XCTAssertTrue(actions(["MyWorkspace/.git/refs/heads/main"]).contains(.branches)) + } + + func testHeadRefreshesCurrentBranch() { + XCTAssertTrue(actions(["MyWorkspace/.git/HEAD"]).contains(.currentBranch)) + } + + func testConfigRefreshesRemotes() { + XCTAssertTrue(actions(["MyWorkspace/.git/config"]).contains(.remotes)) + } + + func testGitFolderRefreshesValidate() { + XCTAssertTrue(actions(["MyWorkspace/.git"]).contains(.validate)) + } +} diff --git a/CodeEditModules/Tests/CESourceControlTests/SourceControlViewModelTests.swift b/CodeEditModules/Tests/CESourceControlTests/SourceControlViewModelTests.swift new file mode 100644 index 0000000000..8e2209c583 --- /dev/null +++ b/CodeEditModules/Tests/CESourceControlTests/SourceControlViewModelTests.swift @@ -0,0 +1,69 @@ +// +// SourceControlViewModelTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 02/07/2026. +// + +@testable import CESourceControl +import XCTest + +@MainActor +final class SourceControlViewModelTests: XCTestCase { + /// Built lazily rather than in `setUp`, which overrides a nonisolated `XCTestCase` method and so + /// cannot touch this `@MainActor` class's state. XCTest creates a fresh test-case instance per + /// test method, so each test still gets its own view model. + private lazy var viewModel = SourceControlViewModel() + + // MARK: - Operation field reset + + func testPushSheetPresentedResetsOperationFields() { + viewModel.operationRebase = true + viewModel.operationForce = true + viewModel.operationIncludeTags = true + + viewModel.pushSheetIsPresented = true + + XCTAssertNil(viewModel.operationBranch) + XCTAssertNil(viewModel.operationRemote) + XCTAssertFalse(viewModel.operationRebase) + XCTAssertFalse(viewModel.operationForce) + XCTAssertFalse(viewModel.operationIncludeTags) + } + + func testPullSheetPresentedResetsOperationFields() { + viewModel.operationRebase = true + viewModel.operationForce = true + viewModel.operationIncludeTags = true + + viewModel.pullSheetIsPresented = true + + XCTAssertNil(viewModel.operationBranch) + XCTAssertNil(viewModel.operationRemote) + XCTAssertFalse(viewModel.operationRebase) + XCTAssertFalse(viewModel.operationForce) + XCTAssertFalse(viewModel.operationIncludeTags) + } + + // MARK: - Sheet independence + + func testSheetBooleansAreIndependent() { + viewModel.pushSheetIsPresented = true + + XCTAssertFalse(viewModel.pullSheetIsPresented) + XCTAssertFalse(viewModel.fetchSheetIsPresented) + XCTAssertFalse(viewModel.stashSheetIsPresented) + XCTAssertFalse(viewModel.addExistingRemoteSheetIsPresented) + } + + // MARK: - Alert independence + + func testAlertBooleansAreIndependent() { + viewModel.discardAllAlertIsPresented = true + + XCTAssertFalse(viewModel.noChangesToStageAlertIsPresented) + XCTAssertFalse(viewModel.noChangesToUnstageAlertIsPresented) + XCTAssertFalse(viewModel.noChangesToStashAlertIsPresented) + XCTAssertFalse(viewModel.noChangesToDiscardAlertIsPresented) + } +} diff --git a/CodeEditModules/Tests/CodeEditCoreTests/ActiveThemeTests.swift b/CodeEditModules/Tests/CodeEditCoreTests/ActiveThemeTests.swift new file mode 100644 index 0000000000..b71f0da1db --- /dev/null +++ b/CodeEditModules/Tests/CodeEditCoreTests/ActiveThemeTests.swift @@ -0,0 +1,90 @@ +// +// ActiveThemeTests.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/08/26. +// + +import Combine +import Testing +@testable import CodeEditCore + +@MainActor +struct ActiveThemeTests { + + /// A change must reach observers — this is the whole reason the type exists. + /// + /// The count is asserted as "at least one": `update` assigns both `@Published` properties + /// unconditionally, so one call emits twice. Only *reaching* observers is the contract. + @Test + func publishesWhenTheCurrentThemeChanges() { + let active = ActiveTheme() + var emissions = 0 + let token = active.objectWillChange.sink { _ in emissions += 1 } + + active.update(current: Self.makeTheme(name: "Solarized"), dark: nil) + + #expect(emissions >= 1) + #expect(active.current?.name == "Solarized") + token.cancel() + } + + /// Editing a colour of the *active* theme must reach observers, and must be stored. + /// + /// ``Theme`` is `Equatable` by name, so an edited copy of the active theme compares *equal* to + /// it. An equality guard in `update` therefore dropped this write entirely: the editor and the + /// terminal kept rendering the old colours while the settings preview showed the new ones. + @Test + func publishesAndStoresAThemeEditedUnderTheSameName() { + let original = Self.makeTheme(name: "Solarized", editorText: "#000000") + let edited = Self.makeTheme(name: "Solarized", editorText: "#FF00FF") + let active = ActiveTheme() + active.update(current: original, dark: nil) + + var emissions = 0 + let token = active.objectWillChange.sink { _ in emissions += 1 } + + active.update(current: edited, dark: nil) + + #expect(original == edited, "Precondition: Theme equality is by name, not by value.") + #expect(emissions >= 1) + #expect(active.current?.editor.text.color == "#FF00FF") + token.cancel() + } + + // MARK: - Fixture + + private static func attr() -> Theme.Attributes { + Theme.Attributes(color: "#000000") + } + + private static func makeTheme(name: String, editorText: String = "#000000") -> Theme { + let editor = Theme.EditorColors( + text: Theme.Attributes(color: editorText), + insertionPoint: attr(), invisibles: attr(), background: attr(), + lineHighlight: attr(), selection: attr(), keywords: attr(), commands: attr(), + types: attr(), attributes: attr(), variables: attr(), values: attr(), + numbers: attr(), strings: attr(), characters: attr(), comments: attr() + ) + let terminal = Theme.TerminalColors( + text: attr(), boldText: attr(), cursor: attr(), background: attr(), selection: attr(), + black: attr(), red: attr(), green: attr(), yellow: attr(), blue: attr(), magenta: attr(), + cyan: attr(), white: attr(), brightBlack: attr(), brightRed: attr(), brightGreen: attr(), + brightYellow: attr(), brightBlue: attr(), brightMagenta: attr(), brightCyan: attr(), + brightWhite: attr() + ) + return Theme( + editor: editor, + terminal: terminal, + author: "Test", + license: "MIT", + metadataDescription: "Test theme", + distributionURL: "", + isBundled: false, + name: name, + displayName: name, + appearance: .dark, + version: "1.0" + ) + } +} diff --git a/CodeEditModules/Tests/CodeEditCoreTests/CEWorkspaceFileCoreTests.swift b/CodeEditModules/Tests/CodeEditCoreTests/CEWorkspaceFileCoreTests.swift new file mode 100644 index 0000000000..70a1613cf1 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditCoreTests/CEWorkspaceFileCoreTests.swift @@ -0,0 +1,69 @@ +// +// CEWorkspaceFileCoreTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import XCTest +import CodeEditCore + +final class CEWorkspaceFileCoreTests: XCTestCase { + + func testName() { + let file = CEWorkspaceFile(url: URL(filePath: "/tmp/Package.swift")) + XCTAssertEqual(file.name, "Package.swift") + } + + // `testTypeDefaultsToTxt` lived here to cover the `FileType` fallback. That enum was + // presentation, not domain, and is gone; the behaviour it guarded — an unrecognised + // extension still resolving to something sensible — is covered by + // `FileIconTests.testUndeclaredExtensionsFallThroughToBareDoc` in CodeEditUIUnitTests. + + func testFileNameTypeHidden() { + let file = CEWorkspaceFile(url: URL(filePath: "/tmp/Model.swift")) + XCTAssertEqual(file.fileName(typeHidden: true), "Model") + XCTAssertEqual(file.fileName(typeHidden: false), "Model.swift") + } + + func testConvenienceInitUsesRelativePathAsID() { + let url = URL(filePath: "/tmp/a/b.txt") + XCTAssertEqual(CEWorkspaceFile(url: url).id, url.relativePath) + } + + func testEqualityByID() { + let url = URL(filePath: "/tmp/x.swift") + XCTAssertEqual(CEWorkspaceFile(url: url), CEWorkspaceFile(url: url)) + XCTAssertNotEqual( + CEWorkspaceFile(url: URL(filePath: "/tmp/x.swift")), + CEWorkspaceFile(url: URL(filePath: "/tmp/y.swift")) + ) + } + + func testComparableByLastPathComponent() { + let apple = CEWorkspaceFile(url: URL(filePath: "/tmp/a.swift")) + let banana = CEWorkspaceFile(url: URL(filePath: "/tmp/b.swift")) + XCTAssertTrue(apple < banana) + } + + func testCodableRoundTrip() throws { + // Note: the encoder writes `changeType`/`staged` unconditionally and the decoder + // requires them non-null, so a valid round-trip needs a concrete gitStatus. + let original = CEWorkspaceFile(url: URL(filePath: "/tmp/File.swift"), changeType: .modified, staged: true) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CEWorkspaceFile.self, from: data) + XCTAssertEqual(decoded.id, original.id) + XCTAssertEqual(decoded.url, original.url) + XCTAssertEqual(decoded.gitStatus, .modified) + XCTAssertEqual(decoded.staged, true) + } + + func testParentWiring() { + let parent = CEWorkspaceFile(url: URL(filePath: "/tmp/folder")) + let child = CEWorkspaceFile(url: URL(filePath: "/tmp/folder/child.swift")) + child.parent = parent + XCTAssertTrue(parent.isRoot) + XCTAssertFalse(child.isRoot) + XCTAssertIdentical(child.parent, parent) + } +} diff --git a/CodeEditModules/Tests/CodeEditCoreTests/FuzzyMatchTests.swift b/CodeEditModules/Tests/CodeEditCoreTests/FuzzyMatchTests.swift new file mode 100644 index 0000000000..7ff9f255b2 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditCoreTests/FuzzyMatchTests.swift @@ -0,0 +1,75 @@ +// +// FuzzyMatchTests.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/07/26. +// + +import Testing +import Foundation +import CodeEditCore + +private struct TestSearchable: FuzzyMatchable, Sendable, Equatable { + let id: Int + let searchableString: String + + init(_ id: Int = 0, _ searchableString: String) { + self.id = id + self.searchableString = searchableString + } +} + +struct FuzzyMatchTests { + @Test + func normalisation() { + #expect("ü".normalise()[0].normalisedContent == "u") + #expect("ñ".normalise()[0].normalisedContent == "n") + #expect("é".normalise()[0].normalisedContent == "e") + } + + @Test + func matchWeightReflectsContainment() { + let item = TestSearchable(0, "ContentView.swift") + #expect(item.fuzzyMatch(query: "CV").weight > 0) + #expect(item.fuzzyMatch(query: "conv").weight > 0) + #expect(item.fuzzyMatch(query: "xyz").weight == 0) + } + + @Test + func matchedPartsCoverTheQuery() { + let item = TestSearchable(0, "ContentView.swift") + let string = item.searchableString + + let substrings = item.fuzzyMatch(query: "ConVie").matchedParts.compactMap { part in + Range(part, in: string).map { String(string[$0]) } + } + + #expect(substrings == ["Con", "Vie"]) + } + + @Test + func searchSortsByDescendingWeightAndDropsNonMatches() async { + let items = [ + TestSearchable(0, "FuzzyMatchable.swift"), + TestSearchable(1, "README.md"), + TestSearchable(2, "FuzzyMatch.swift") + ] + + let results = await items.fuzzyMatches(query: "fuzzy") + + #expect(results.count == 2) + #expect(results.allSatisfy { $0.result.weight > 0 }) + #expect(results.map(\.result.weight) == results.map(\.result.weight).sorted(by: >)) + } + + @Test + func searchPreservesInputOrderForEqualWeights() async { + // Identical searchable strings produce identical weights; the sort is stable, + // so the result order must match the input order. + let items = (0..<50).map { TestSearchable($0, "SameName.swift") } + + let results = await items.fuzzyMatches(query: "same").map(\.item) + + #expect(results == items) + } +} diff --git a/CodeEditModules/Tests/CodeEditCoreTests/WorkspaceEventsTests.swift b/CodeEditModules/Tests/CodeEditCoreTests/WorkspaceEventsTests.swift new file mode 100644 index 0000000000..a29ecb7c53 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditCoreTests/WorkspaceEventsTests.swift @@ -0,0 +1,29 @@ +// +// WorkspaceEventsTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import XCTest +import CodeEditCore + +final class WorkspaceEventsTests: XCTestCase { + func testWorkspaceFileEventCarriesKindAndURL() { + let url = URL(filePath: "/tmp/ws") + let event = WorkspaceFileEvent(workspaceURL: url, kind: .filesystemChanged(paths: ["a.swift"])) + XCTAssertEqual(event.workspaceURL, url) + if case let .filesystemChanged(paths) = event.kind { + XCTAssertEqual(paths, ["a.swift"]) + } else { + XCTFail("wrong kind") + } + } + + func testGitStatusChangedEventCarriesMap() { + let url = URL(filePath: "/tmp/ws") + let event = GitStatusChangedEvent(workspaceURL: url, changed: ["a.swift": .modified]) + XCTAssertEqual(event.workspaceURL, url) + XCTAssertEqual(event.changed["a.swift"], .modified) + } +} diff --git a/CodeEditModules/Tests/CodeEditCoreTests/WorkspaceSettingsValueTypeTests.swift b/CodeEditModules/Tests/CodeEditCoreTests/WorkspaceSettingsValueTypeTests.swift new file mode 100644 index 0000000000..e4c72abf06 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditCoreTests/WorkspaceSettingsValueTypeTests.swift @@ -0,0 +1,92 @@ +// +// WorkspaceSettingsValueTypeTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 05/07/2026. +// + +import XCTest +import CodeEditCore + +final class WorkspaceSettingsValueTypeTests: XCTestCase { + + // MARK: - CETask computed properties + + func testFullCommandWithoutWorkingDirectory() { + let task = CETask(name: "Build", command: "swift build") + XCTAssertEqual(task.fullCommand, "swift build") + } + + func testFullCommandWithWorkingDirectory() { + let task = CETask(workingDirectory: "/tmp/my project", command: "swift build") + XCTAssertEqual(task.fullCommand, #"cd "/tmp/my project" && swift build"#) + } + + func testEnvironmentVariablesDictionary() { + let task = CETask( + command: "run", + environmentVariables: [ + .init(key: "A", value: "1"), + .init(key: "B", value: "2") + ] + ) + XCTAssertEqual(task.environmentVariablesDictionary, ["A": "1", "B": "2"]) + } + + func testIsInvalid() { + XCTAssertTrue(CETask(name: "", command: "x").isInvalid) + XCTAssertTrue(CETask(name: "x", command: "").isInvalid) + XCTAssertFalse(CETask(name: "x", command: "y").isInvalid) + } + + // MARK: - Codable round-trips (on-disk contract) + + func testCETaskRoundTripPreservesFields() throws { + let task = CETask( + name: "Build", + target: "SSH", + workingDirectory: "/tmp", + command: "swift build", + environmentVariables: [.init(key: "K", value: "V")] + ) + let data = try JSONEncoder().encode(task) + let decoded = try JSONDecoder().decode(CETask.self, from: data) + + XCTAssertEqual(decoded.name, "Build") + XCTAssertEqual(decoded.target, "SSH") + XCTAssertEqual(decoded.workingDirectory, "/tmp") + XCTAssertEqual(decoded.command, "swift build") + XCTAssertEqual(decoded.environmentVariablesDictionary, ["K": "V"]) + } + + func testCETaskOmitsDefaultTargetAndEmptyFields() throws { + let task = CETask(name: "Build", target: "My Mac", command: "swift build") + let json = String(data: try JSONEncoder().encode(task), encoding: .utf8) ?? "" + // "My Mac" is the implicit default and must not be written; empty workingDirectory omitted. + XCTAssertFalse(json.contains("My Mac")) + XCTAssertFalse(json.contains("workingDirectory")) + XCTAssertTrue(json.contains("swift build")) + } + + func testSettingsDataDefaultsMissingKeys() throws { + let decoded = try JSONDecoder().decode(CEWorkspaceSettingsData.self, from: Data("{}".utf8)) + XCTAssertTrue(decoded.isEmpty()) + XCTAssertEqual(decoded.tasks.count, 0) + XCTAssertEqual(decoded.project.projectName, "") + } + + func testSettingsDataIsEmpty() { + XCTAssertTrue(CEWorkspaceSettingsData().isEmpty()) + var withName = CEWorkspaceSettingsData() + withName.project.projectName = "MyProject" + XCTAssertFalse(withName.isEmpty()) + var withTask = CEWorkspaceSettingsData() + withTask.tasks.append(CETask(name: "t", command: "c")) + XCTAssertFalse(withTask.isEmpty()) + } + + func testEmptySettingsEncodeToEmptyObject() throws { + let json = String(data: try JSONEncoder().encode(CEWorkspaceSettingsData()), encoding: .utf8) ?? "" + XCTAssertEqual(json, "{}") + } +} diff --git a/CodeEditModules/Tests/CodeEditSettingsTests/Fixtures/full-settings.json b/CodeEditModules/Tests/CodeEditSettingsTests/Fixtures/full-settings.json new file mode 100644 index 0000000000..370dd19b29 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditSettingsTests/Fixtures/full-settings.json @@ -0,0 +1,161 @@ +{ + "accounts" : { + "sourceControlAccounts" : { + "gitAccounts" : [ + + ], + "sshKey" : "/Users/test/.ssh/id_ed25519" + } + }, + "developerSettings" : { + "lspBinaries" : { + + }, + "showInternalDevelopmentInspector" : true + }, + "general" : { + "appAppearance" : "dark", + "dimEditorsWithoutFocus" : true, + "fileExtensionsVisibility" : { + "hideAll" : { + + } + }, + "fileIconStyle" : "monochrome", + "findNavigatorDetail" : 10, + "hiddenFileExtensions" : { + "extensions" : [ + "log", + "tmp" + ] + }, + "inspectorTabBarPosition" : "side", + "isAutoSaveOn" : false, + "issueNavigatorDetail" : 30, + "navigatorTabBarPosition" : "side", + "projectNavigatorSize" : "large", + "reopenBehavior" : "newDocument", + "reopenWindowAfterClose" : "quit", + "revealFileOnFocusChange" : true, + "showEditorJumpBar" : false, + "showIssues" : "minimized", + "showLiveIssues" : false, + "shownFileExtensions" : { + "extensions" : [ + "rs", + "toml" + ] + } + }, + "keybindings" : { + "keybindings" : { + + } + }, + "languageServers" : { + "installedLanguageServers" : { + + } + }, + "navigation" : { + "navigationStyle" : "openInPlace" + }, + "search" : { + "ignoreGlobPatterns" : [ + + ] + }, + "sourceControl" : { + "general" : { + "addRemoveAutomatically" : false, + "controlNavigatorOrder" : "sortByDate", + "fetchRefreshServerStatus" : false, + "includeUpstreamChanges" : false, + "openFeedbackInBrowser" : false, + "refreshStatusLocally" : false, + "revisionComparisonLayout" : "localRight", + "selectFilesToCommit" : false, + "showSourceControlChanges" : false, + "sourceControlIsEnabled" : false + }, + "git" : { + "showMergeCommitsPerFileLog" : true + } + }, + "terminal" : { + "cursorBlink" : true, + "cursorStyle" : "bar", + "darkAppearance" : true, + "font" : { + "name" : "Menlo", + "size" : 14, + "weight" : 0.5 + }, + "optionAsMeta" : true, + "shell" : "zsh", + "useEditorTheme" : false, + "useLoginShell" : false, + "useShellIntegration" : false, + "useTextEditorFont" : false, + "useThemeBackground" : false + }, + "textEditing" : { + "autocompleteBraces" : false, + "bracketEmphasis" : { + "color" : { + "color" : "FF0000" + }, + "highlightType" : "bordered", + "useCustomColor" : true + }, + "defaultTabWidth" : 8, + "enableTypeOverCompletion" : false, + "font" : { + "name" : "Menlo", + "size" : 16, + "weight" : 0.5 + }, + "indentOption" : { + "indentType" : "tab", + "spaceCount" : 2 + }, + "invisibleCharacters" : { + "carriageReturnReplacement" : "R", + "enabled" : true, + "lineFeedReplacement" : "F", + "lineSeparatorReplacement" : "L", + "paragraphSeparatorReplacement" : "P", + "showLineEndings" : false, + "showSpaces" : false, + "showTabs" : false, + "spaceReplacement" : "S", + "tabReplacement" : "T" + }, + "letterSpacing" : 1.5, + "lineHeightMultiple" : 1.5, + "overscroll" : "large", + "reformatAtColumn" : 100, + "showFoldingRibbon" : false, + "showGutter" : false, + "showMinimap" : false, + "showReformattingGuide" : true, + "useSystemCursor" : false, + "warningCharacters" : { + "characters" : [ + 894, + "Greek Question Mark" + ], + "enabled" : false + }, + "wrapLinesToEditorWidth" : false + }, + "theme" : { + "matchAppearance" : false, + "overrides" : { + + }, + "selectedDarkTheme" : "Solarized (Dark)", + "selectedLightTheme" : "Solarized (Light)", + "useThemeBackground" : false + } +} diff --git a/CodeEditModules/Tests/CodeEditSettingsTests/Fixtures/unknown-sections.json b/CodeEditModules/Tests/CodeEditSettingsTests/Fixtures/unknown-sections.json new file mode 100644 index 0000000000..a2cd2fa9eb --- /dev/null +++ b/CodeEditModules/Tests/CodeEditSettingsTests/Fixtures/unknown-sections.json @@ -0,0 +1,15 @@ +{ + "general" : { + "fileIconStyle" : "monochrome" + }, + "someFutureFeature" : { + "enabled" : true, + "threshold" : 42 + }, + "extensions" : { + "com.example.myextension" : { + "apiKey" : "abc123", + "nested" : { "deep" : [ 1, 2, 3 ] } + } + } +} diff --git a/CodeEditModules/Tests/CodeEditSettingsTests/SettingsFormatTests.swift b/CodeEditModules/Tests/CodeEditSettingsTests/SettingsFormatTests.swift new file mode 100644 index 0000000000..a1a14e1bc8 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditSettingsTests/SettingsFormatTests.swift @@ -0,0 +1,191 @@ +// +// SettingsFormatTests.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 09/08/26. +// + +import Testing +import Foundation +import CELSP +import CESourceControl +import CETerminal +@testable import CodeEditSettings + +struct SettingsFormatTests { + + private func fixture(_ name: String) throws -> Data { + let url = try #require( + Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") + ) + return try Data(contentsOf: url) + } + + private func parsed(_ data: Data) throws -> NSDictionary { + try #require(JSONSerialization.jsonObject(with: data) as? NSDictionary) + } + + /// Decoding then encoding a fully-populated settings file must not change any key or value. + /// + /// The fixture deliberately holds no default values: a fixture of defaults would pass even if + /// decoding silently fell back to defaults, which is the failure this guards against. + /// + /// It used to round-trip a `SettingsData`, which is no longer possible: that aggregate is an + /// app-side façade over this store and is not `Codable`. Every section is instead read *and + /// written back* through the store, which is the same work: reading alone would prove nothing, + /// because an unread section is re-emitted as the raw JSON it came in as. Writing the decoded + /// value back replaces the stored JSON with whatever the Swift type produces — exactly the step + /// that could silently drop or rename a key. + @Test + func fullSettingsRoundTripsUnchanged() throws { + let original = try fixture("full-settings") + let store = try SettingsStore(data: original) + + func reencode(_ type: S.Type) { + let decoded = store[S.self] + store[S.self] = decoded + } + + reencode(GeneralSettings.self) + reencode(AccountsSettings.self) + reencode(NavigationSettings.self) + reencode(ThemeSettings.self) + reencode(TextEditingSettings.self) + reencode(TerminalSettings.self) + reencode(SourceControlSettings.self) + reencode(KeybindingsSettings.self) + reencode(SearchSettings.self) + reencode(LanguageServerSettings.self) + reencode(DeveloperSettings.self) + + #expect(try parsed(store.encoded()) == parsed(original)) + } + + /// Section keys must match the JSON keys `SettingsData` already uses, or existing settings + /// files silently orphan their values. + /// + /// Note `developerSettings`, not `developer` — the key is the existing field name. + @Test + func sectionKeysMatchTheOnDiskKeys() { + #expect(GeneralSettings.settingsKey == "general") + #expect(AccountsSettings.settingsKey == "accounts") + #expect(NavigationSettings.settingsKey == "navigation") + #expect(ThemeSettings.settingsKey == "theme") + #expect(TextEditingSettings.settingsKey == "textEditing") + #expect(TerminalSettings.settingsKey == "terminal") + #expect(SourceControlSettings.settingsKey == "sourceControl") + #expect(KeybindingsSettings.settingsKey == "keybindings") + #expect(SearchSettings.settingsKey == "search") + #expect(LanguageServerSettings.settingsKey == "languageServers") + #expect(DeveloperSettings.settingsKey == "developerSettings") + } + + @Test + func snapshotReaderReturnsTheGivenSection() { + var terminal = TerminalSettings() + terminal.cursorBlink = true + let reader = SnapshotSettingsReader([TerminalSettings.settingsKey: terminal]) + + #expect(reader.value(TerminalSettings.self).cursorBlink == true) + } + + @Test + func snapshotReaderFallsBackToDefaults() { + let reader = SnapshotSettingsReader([:]) + + #expect(reader.value(TerminalSettings.self) == TerminalSettings()) + } + + /// Every section present on disk must survive a save, whether or not anything is registered to + /// read it. This is what keeps a disabled or not-yet-loaded extension's configuration alive. + @Test + func unknownSectionsSurviveASave() throws { + let original = try fixture("unknown-sections") + let store = try SettingsStore(data: original) + let saved = try store.encoded() + + let before = try parsed(original) + let after = try parsed(saved) + #expect(after["someFutureFeature"] as? NSDictionary == before["someFutureFeature"] as? NSDictionary) + #expect(after["extensions"] as? NSDictionary == before["extensions"] as? NSDictionary) + } + + /// A registered section must survive the store's decode/encode cycle with non-default values + /// intact — not merely be replaced by a section rebuilt at defaults. + @Test + func registeredSectionRoundTripsThroughTheStore() throws { + let store = try SettingsStore(data: fixture("full-settings")) + + var terminal = store[TerminalSettings.self] + #expect(terminal.cursorBlink == true, "fixture seeds a non-default value") + terminal.cursorStyle = .underline + store[TerminalSettings.self] = terminal + + let reloaded = try SettingsStore(data: store.encoded()) + #expect(reloaded[TerminalSettings.self].cursorStyle == .underline) + #expect(reloaded[TerminalSettings.self].cursorBlink == true, "untouched field survived") + } + + /// A section this build cannot decode must survive a save untouched for as long as nothing + /// writes it — reading it as defaults must not make those defaults the stored value. + @Test + func anUndecodableSectionIsReEmittedVerbatim() throws { + let original = Data(#"{"theme":["hand","edited"],"general":{"fileIconStyle":"monochrome"}}"#.utf8) + let store = try SettingsStore(data: original) + + #expect(store[ThemeSettings.self] == ThemeSettings(), "an unreadable section reads as defaults") + + // A write to an *unrelated* section must not take the broken one down with it. + var general = store[GeneralSettings.self] + general.fileIconStyle = .color + store[GeneralSettings.self] = general + + let after = try parsed(store.encoded()) + #expect(after["theme"] as? NSArray == ["hand", "edited"] as NSArray) + } + + /// Writing a section whose stored value could not be decoded is the one destructive operation in + /// the store, and it must announce itself before it happens. + /// + /// The handler is what lets `PersistentSettingsStore` copy the file aside first. Asserting on the values + /// instead would prove nothing: they are the same defaults either way. + @Test + func replacingAnUndecodableSectionIsAnnouncedOnce() throws { + let store = try SettingsStore(data: Data(#"{"theme":[],"general":{}}"#.utf8)) + + var announced: [String] = [] + store.willReplaceUndecodableSection = { announced.append($0) } + + // A decodable section is replaced silently — nothing of the user's is lost. + store[GeneralSettings.self] = store[GeneralSettings.self] + #expect(announced.isEmpty) + + store[ThemeSettings.self] = store[ThemeSettings.self] + #expect(announced == ["theme"]) + + // The original is gone now, so a repeat write has nothing left to announce. + store[ThemeSettings.self] = store[ThemeSettings.self] + #expect(announced == ["theme"]) + } + + /// Loading and saving through `Settings` must not drop sections it has no field for. + /// + /// This is the guarantee extensions depend on: a user who disables an extension must not lose + /// its configuration the next time anything else is saved. + @Test + @MainActor + func savingPreservesSectionsSettingsDataDoesNotKnow() throws { + let original = try fixture("unknown-sections") + let store = try SettingsStore(data: original) + + // Simulate the load → mutate a known section → save cycle `Settings` performs. + var general = store[GeneralSettings.self] + general.fileIconStyle = .color + store[GeneralSettings.self] = general + + let after = try parsed(store.encoded()) + let before = try parsed(original) + #expect(after["someFutureFeature"] as? NSDictionary == before["someFutureFeature"] as? NSDictionary) + #expect(after["extensions"] as? NSDictionary == before["extensions"] as? NSDictionary) + } +} diff --git a/CodeEditModules/Tests/CodeEditSettingsTests/SettingsStoreTests.swift b/CodeEditModules/Tests/CodeEditSettingsTests/SettingsStoreTests.swift new file mode 100644 index 0000000000..3bd64aea16 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditSettingsTests/SettingsStoreTests.swift @@ -0,0 +1,90 @@ +// +// SettingsStoreTests.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 16/08/26. +// + +import Testing +import Foundation +@testable import CodeEditSettings + +/// Guards the two store behaviours nothing else pins. +/// +/// Both were previously asserted only in doc comments, and both are load-bearing: the first is what +/// third-party extension settings will rely on, the second is what makes a settings change visible +/// to a view body re-evaluated by the change itself. +struct SettingsStoreTests { + + // MARK: - Preservation + + /// A section nothing in this build decodes must survive a save by this build. + /// + /// This is the guarantee an extension's configuration depends on: a newer build writes a key, + /// an older build loads and saves, and the key is still there afterwards. + @Test + func preservesSectionsNoTypeClaims() throws { + let raw = Data(#"{"general":{"revealFileOnFocusChange":true},"com.example.ext":{"enabled":true}}"#.utf8) + + let store = try SettingsStore(data: raw) + store[GeneralSettings.self] = store[GeneralSettings.self] + + let object = try #require( + JSONSerialization.jsonObject(with: try store.encoded()) as? [String: Any] + ) + let extensionSection = try #require( + object["com.example.ext"] as? [String: Any], + "a section no type decodes must survive a write by this build" + ) + #expect(extensionSection["enabled"] as? Bool == true, "its contents must survive verbatim") + } + + /// An undecodable section is likewise held verbatim, rather than being dropped or defaulted on + /// disk, until something writes that same section back. + @Test + func preservesASectionThisBuildCannotDecode() throws { + let raw = Data(#"{"general":"this is not an object","com.example.ext":{"enabled":true}}"#.utf8) + + let store = try SettingsStore(data: raw) + _ = store[GeneralSettings.self] + + let object = try #require( + JSONSerialization.jsonObject(with: try store.encoded()) as? [String: Any] + ) + #expect( + object["general"] as? String == "this is not an object", + "reading an undecodable section must not replace it on disk" + ) + } + + // MARK: - Ordering + + /// Observers must see the **new** value, not the value being replaced. + /// + /// `PersistentSettingsStore` mutates its backing store before bumping `revision`, so a view body + /// re-evaluated by the publish already reads the new value. Inverting that order would make + /// every settings change appear one edit behind. + @MainActor + @Test + func publishesOnlyAfterTheNewValueIsReadable() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("settings-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + + let store = PersistentSettingsStore(settingsURL: url) + + var section = store.value(GeneralSettings.self) + section.revealFileOnFocusChange.toggle() + let expected = section.revealFileOnFocusChange + + var observed: Bool? + let token = store.objectWillChange.sink { _ in + observed = store.value(GeneralSettings.self).revealFileOnFocusChange + } + defer { token.cancel() } + + store.setValue(section) + + #expect(observed == expected, "an observer must read the new value, not the one replaced") + } +} diff --git a/CodeEditModules/Tests/CodeEditUIUnitTests/AtomConstructionTests.swift b/CodeEditModules/Tests/CodeEditUIUnitTests/AtomConstructionTests.swift new file mode 100644 index 0000000000..9b51be53b3 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditUIUnitTests/AtomConstructionTests.swift @@ -0,0 +1,59 @@ +// +// AtomConstructionTests.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 15/07/26. +// + +import Testing +import SwiftUI +import AppKit +import CodeEditUI + +/// Construction/behavior smoke tests for the shared atoms formerly covered by the +/// (plan-skipped, deleted) pixel-snapshot suite. Views are materialized in an +/// offscreen window; no reference images. +@MainActor +struct AtomConstructionTests { + private func materialize(_ view: some View, appearance: NSAppearance.Name) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + window.appearance = NSAppearance(named: appearance) + window.contentView = NSHostingView(rootView: view) + window.layoutIfNeeded() + return window + } + + @Test(arguments: [NSAppearance.Name.aqua, .darkAqua]) + func helpButtonHasIntrinsicSize(appearance: NSAppearance.Name) throws { + let window = materialize(HelpButton(action: {}), appearance: appearance) + let hosting = try #require(window.contentView as? NSHostingView) + #expect(hosting.fittingSize.width > 0) + #expect(hosting.fittingSize.height > 0) + } + + @Test(arguments: [false, true]) + func segmentedControlHasIntrinsicSize(prominent: Bool) throws { + let view = SegmentedControl(.constant(0), options: ["One", "Two"], prominent: prominent) + let window = materialize(view, appearance: .aqua) + let hosting = try #require(window.contentView as? NSHostingView) + #expect(hosting.fittingSize.width > 0) + #expect(hosting.fittingSize.height > 0) + } + + @Test + func effectViewMaterializesAVisualEffectView() throws { + let window = materialize(EffectView(), appearance: .aqua) + + func containsVisualEffectView(_ view: NSView) -> Bool { + if view is NSVisualEffectView { return true } + return view.subviews.contains(where: containsVisualEffectView) + } + + #expect(containsVisualEffectView(try #require(window.contentView))) + } +} diff --git a/CodeEditModules/Tests/CodeEditUIUnitTests/FileIconTests.swift b/CodeEditModules/Tests/CodeEditUIUnitTests/FileIconTests.swift new file mode 100644 index 0000000000..8e9430da39 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditUIUnitTests/FileIconTests.swift @@ -0,0 +1,118 @@ +// +// FileIconTests.swift +// CodeEditUIUnitTests +// +// Created by Matthijs Eikelenboom on 05/08/2026. +// + +import XCTest +import SwiftUI +@testable import CodeEditUI + +/// The table itself was proven exhaustively against the pre-existing mapping by a +/// temporary parity test in the app target — see commit `ba5beee2`, which compared +/// every one of the 73 legacy file types for both symbol and colour. These tests +/// guard the behaviour that survives it. +final class FileIconTests: XCTestCase { + + private func spec(_ filename: String) -> FileIconSpec { + FileIcon.spec(for: URL(fileURLWithPath: "/tmp/\(filename)")) + } + + func testRepresentativeExtensions() { + XCTAssertEqual(spec("main.swift").symbol, "swift") + XCTAssertEqual(spec("Package.resolved").symbol, "doc.json") + XCTAssertEqual(spec("script.py").symbol, "doc.python") + XCTAssertEqual(spec("photo.jpg").symbol, "photo") + } + + func testWholeFilenameAndDotfileMatches() { + XCTAssertEqual(spec("LICENSE").symbol, "key.fill") + XCTAssertEqual(spec("Makefile").symbol, "terminal") + XCTAssertEqual(spec(".gitignore").symbol, "arrow.triangle.branch") + XCTAssertEqual(spec(".env").symbol, "gearshape.fill") + XCTAssertEqual(spec(".env.example").symbol, "gearshape.fill") + } + + func testLastExtensionWins() { + XCTAssertEqual(spec("types.d.ts").symbol, "t.square") + } + + func testTheFourDeliberateFixes() { + XCTAssertEqual(spec("app.ts").symbol, "t.square") + XCTAssertEqual(spec("main.c").symbol, "c.square") + XCTAssertEqual(NSColor(spec("a.jpeg").color), NSColor(spec("a.jpg").color)) + XCTAssertEqual(spec("mystery.qqzz").symbol, "doc") + } + + func testKnownPlainTextLanguagesDoNotRegressToBareDoc() { + for ext in ["md", "txt", "kt", "lua", "hs", "dart"] { + XCTAssertEqual(spec("file.\(ext)").symbol, "doc.plaintext", "\(ext) regressed") + } + } + + /// Extensions absent from the table but declared to the system still get a + /// meaningful icon. Only types macOS actually declares are asserted here — + /// `.mkv`, for instance, is undeclared on a stock install (see the test below). + func testSystemFallbackIdentifiesDeclaredTypes() { + XCTAssertEqual(spec("art.heic").symbol, "photo") + XCTAssertEqual(spec("art.webp").symbol, "photo") + XCTAssertEqual(spec("clip.webm").symbol, "film") + XCTAssertEqual(spec("song.flac").symbol, "speaker.wave.2") + XCTAssertEqual(spec("Config.toml").symbol, "doc.plaintext") + } + + /// `UTType(filenameExtension:)` does **not** return nil for unknown extensions — + /// it synthesises a dynamic `dyn.…` type that conforms to nothing. Undeclared + /// extensions therefore fall through the conformance checks to bare `doc`. + func testUndeclaredExtensionsFallThroughToBareDoc() { + for ext in ["mkv", "zig", "qqzz"] { + XCTAssertEqual(spec("file.\(ext)").symbol, "doc", "\(ext) should fall through") + } + } + + func testFolderSpecs() { + XCTAssertEqual( + FileIcon.folderSpec(isEmpty: false, isRoot: true, isCodeEditDirectory: false).symbol, + "folder.fill.badge.gearshape" + ) + XCTAssertEqual( + FileIcon.folderSpec(isEmpty: false, isRoot: false, isCodeEditDirectory: true).symbol, + "folder.fill.badge.gearshape" + ) + XCTAssertEqual( + FileIcon.folderSpec(isEmpty: true, isRoot: false, isCodeEditDirectory: false).symbol, + "folder" + ) + XCTAssertEqual( + FileIcon.folderSpec(isEmpty: false, isRoot: false, isCodeEditDirectory: false).symbol, + "folder.fill" + ) + } + + func testGenericSpec() { + XCTAssertEqual(FileIcon.generic.symbol, "doc") + } + + /// Guards the package resource wiring: if `Resources` is ever dropped from the + /// manifest, every colour silently becomes unresolved rather than failing to build. + func testCustomColorAssetsResolveFromThePackageBundle() { + for name in ["Amber", "Scarlet", "Steel"] { + XCTAssertNotNil( + NSColor(named: name, bundle: .module), + "\(name) missing from the CodeEditUI asset catalog" + ) + } + } + + /// The three custom colours must differ from each other — a failed asset lookup + /// would collapse them to the same fallback and still pass a nil-check. + func testCustomColorsAreDistinct() { + let amber = NSColor(spec("app.js").color) + let scarlet = NSColor(spec("data.json").color) + let steel = NSColor(spec("Info.plist").color) + XCTAssertNotEqual(amber, scarlet) + XCTAssertNotEqual(scarlet, steel) + XCTAssertNotEqual(amber, steel) + } +} diff --git a/CodeEditModules/Tests/CodeEditUIUnitTests/WorkspacePanelContributionTests.swift b/CodeEditModules/Tests/CodeEditUIUnitTests/WorkspacePanelContributionTests.swift new file mode 100644 index 0000000000..f6da79a4c2 --- /dev/null +++ b/CodeEditModules/Tests/CodeEditUIUnitTests/WorkspacePanelContributionTests.swift @@ -0,0 +1,71 @@ +// +// WorkspacePanelContributionTests.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import Testing +import SwiftUI +@testable import CodeEditUI + +private struct StubContribution: WorkspacePanelContribution { + let id: String + let title: String + let systemImage: String + var content: AnyView { AnyView(Color.clear) } +} + +struct WorkspacePanelContributionTests { + + /// A heterogeneous list is the whole point: first-party and extension contributions differ in + /// type but must live in one array. + @Test + func contributionsAreAddressableAsAHeterogeneousList() { + let items: [any WorkspacePanelContribution] = [ + StubContribution(id: "a", title: "Alpha", systemImage: "a.circle"), + StubContribution(id: "b", title: "Beta", systemImage: "b.circle") + ] + + #expect(items.map(\.id) == ["a", "b"]) + #expect(items.first?.title == "Alpha") + } + + // MARK: - Selection reconciliation + + /// Built per call rather than held in a `static let`: the element type is not `Sendable`, so + /// shared mutable state would not compile under the package's strict concurrency. + private func twoTabs() -> [any WorkspacePanelContribution] { + [ + StubContribution(id: "a", title: "Alpha", systemImage: "a.circle"), + StubContribution(id: "b", title: "Beta", systemImage: "b.circle") + ] + } + + /// A selection that still names a present tab must be left alone — reconciliation runs whenever + /// the list changes, so a version that "fixed" valid selections would yank the user's tab away + /// every time an unrelated one appeared. + @Test + func keepsASelectionThatStillExists() { + #expect(twoTabs().reconcilingSelection("b") == "b") + } + + /// The bug this exists for: the inspector rebuilds its tabs when a setting changes, and a + /// selection naming the removed tab left the panel on "No Selection" with no way back. + @Test + func fallsBackToTheFirstTabWhenTheSelectionIsGone() { + #expect(twoTabs().reconcilingSelection("removed") == "a") + } + + @Test + func selectsTheFirstTabWhenNothingIsSelected() { + #expect(twoTabs().reconcilingSelection(nil) == "a") + } + + /// No tabs means no selection — not the previous id, which would keep a dead selection alive. + @Test + func answersNilWhenThereAreNoTabs() { + let empty: [any WorkspacePanelContribution] = [] + #expect(empty.reconcilingSelection("a") == nil) + } +} diff --git a/CodeEditTestPlan.xctestplan b/CodeEditTestPlan.xctestplan index 1b4e456b21..4dce4bc57e 100644 --- a/CodeEditTestPlan.xctestplan +++ b/CodeEditTestPlan.xctestplan @@ -18,27 +18,7 @@ "testTargets" : [ { "skippedTests" : [ - "CodeEditUIUnitTests", - "CodeEditUIUnitTests\/testBranchPickerDark()", - "CodeEditUIUnitTests\/testBranchPickerLight()", - "CodeEditUIUnitTests\/testEffectViewDark()", - "CodeEditUIUnitTests\/testEffectViewLight()", - "CodeEditUIUnitTests\/testFontPickerViewDark()", - "CodeEditUIUnitTests\/testFontPickerViewLight()", - "CodeEditUIUnitTests\/testHelpButtonDark()", - "CodeEditUIUnitTests\/testHelpButtonLight()", - "CodeEditUIUnitTests\/testSegmentedControlDark()", - "CodeEditUIUnitTests\/testSegmentedControlLight()", - "CodeEditUIUnitTests\/testSegmentedControlProminentDark()", - "CodeEditUIUnitTests\/testSegmentedControlProminentLight()", - "RegistryTests", - "WelcomeModuleUnitTests", - "WelcomeModuleUnitTests\/testRecentJSFileDarkSnapshot()", - "WelcomeModuleUnitTests\/testRecentJSFileLightSnapshot()", - "WelcomeModuleUnitTests\/testRecentProjectItemDarkSnapshot()", - "WelcomeModuleUnitTests\/testRecentProjectItemLightSnapshot()", - "WelcomeModuleUnitTests\/testWelcomeActionViewDarkSnapshot()", - "WelcomeModuleUnitTests\/testWelcomeActionViewLightSnapshot()" + "RegistryTests" ], "target" : { "containerPath" : "container:CodeEdit.xcodeproj", @@ -52,6 +32,48 @@ "identifier" : "B658FB4627DA9E1000EA4DBD", "name" : "CodeEditUITests" } + }, + { + "target" : { + "containerPath" : "container:CodeEditModules", + "identifier" : "CodeEditCoreTests", + "name" : "CodeEditCoreTests" + } + }, + { + "target" : { + "containerPath" : "container:CodeEditModules", + "identifier" : "CodeEditSettingsTests", + "name" : "CodeEditSettingsTests" + } + }, + { + "target" : { + "containerPath" : "container:CodeEditModules", + "identifier" : "CESearchTests", + "name" : "CESearchTests" + } + }, + { + "target" : { + "containerPath" : "container:CodeEditModules", + "identifier" : "CodeEditUIUnitTests", + "name" : "CodeEditUIUnitTests" + } + }, + { + "target" : { + "containerPath" : "container:CodeEditModules", + "identifier" : "CELSPTests", + "name" : "CELSPTests" + } + }, + { + "target" : { + "containerPath" : "container:CodeEditModules", + "identifier" : "CESourceControlTests", + "name" : "CESourceControlTests" + } } ], "version" : 1 diff --git a/CodeEditTests/App/PersistentSettingsStoreTests.swift b/CodeEditTests/App/PersistentSettingsStoreTests.swift new file mode 100644 index 0000000000..b069b5a930 --- /dev/null +++ b/CodeEditTests/App/PersistentSettingsStoreTests.swift @@ -0,0 +1,224 @@ +// +// PersistentSettingsStoreTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 09/08/2026. +// + +import CELSP +import CESourceControl +import CETerminal +import Foundation +import Testing +import CodeEditSettings +@testable import CodeEdit + +/// Verifies `PersistentSettingsStore` reads and writes real, persisted settings rather than answering with +/// section defaults like `DefaultSettingsReader` would. A test that only checked defaults would pass +/// against either implementation and prove nothing. +/// +/// Each test builds its own store over a temporary file. The predecessor of this suite mutated the +/// `Settings.shared` singleton and had to restore what it found, serialize against every other suite +/// that touched settings, and could never assert anything about the file on disk. That is the +/// concrete payoff of moving ownership into the composition root. +@MainActor +struct PersistentSettingsStoreTests { + + /// A store over a fresh temporary `settings.json` that no other test can see. + private func makeStore(seed: String? = nil) throws -> (PersistentSettingsStore, URL) { + let directory = URL.temporaryDirectory.appending(path: "PersistentSettingsStoreTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "settings.json") + if let seed { + try Data(seed.utf8).write(to: url) + } + return (PersistentSettingsStore(settingsURL: url), url) + } + + @Test + func readsAValueSeededOnDisk() throws { + // Non-default: `SourceControlGeneral.sourceControlIsEnabled` defaults to `true`, so + // `DefaultSettingsReader` (or a store that never read the file) could not produce `false`. + let (store, _) = try makeStore(seed: #"{"sourceControl":{"general":{"sourceControlIsEnabled":false}}}"#) + + #expect(store.value(SourceControlSettings.self).general.sourceControlIsEnabled == false) + } + + @Test + func writtenSectionReadsBack() throws { + let (store, _) = try makeStore() + + var section = store.value(SourceControlSettings.self) + #expect(section.general.sourceControlIsEnabled == true, "starts at its default") + section.general.sourceControlIsEnabled = false + store.setValue(section) + + #expect(store.value(SourceControlSettings.self).general.sourceControlIsEnabled == false) + } + + @Test + func aWriteBumpsTheRevision() throws { + let (store, _) = try makeStore() + let before = store.revision + + var section = store.value(TerminalSettings.self) + section.cursorBlink = !section.cursorBlink + store.setValue(section) + + #expect(store.revision == before + 1) + } + + /// The relaunch guarantee: a write must reach disk on its own, without anyone calling `save`. + /// + /// This is what the `throttle(for: 2, scheduler: RunLoop.main)` pipeline exists for, so the poll + /// window is deliberately several times the interval rather than a fixed sleep of exactly it. + @Test + func aWriteReachesDiskThroughTheThrottle() async throws { + let (store, url) = try makeStore() + + var section = store.value(LanguageServerSettings.self) + section.installedLanguageServers = [ + "round-trip-test": .init(packageName: "round-trip-test", isEnabled: false, version: "9.9.9") + ] + store.setValue(section) + + var attempts = 0 + while !FileManager.default.fileExists(atPath: url.path) && attempts < 120 { + attempts += 1 + try? await Task.sleep(for: .milliseconds(50)) + } + + let reloaded = PersistentSettingsStore(settingsURL: url) + let readBack = reloaded.value(LanguageServerSettings.self).installedLanguageServers["round-trip-test"] + #expect(readBack?.version == "9.9.9", "a settings write did not survive to disk") + #expect(readBack?.isEnabled == false) + } + + /// An unparseable `settings.json` must be copied aside **before** the empty store it falls back + /// to can be written over it. + /// + /// The fallback itself is not the bug — the app has to launch. Losing the original is, and it is + /// silent: the user sees settings reset to defaults and has nothing left to recover from. + @Test + func anUnreadableFileIsPreservedBeforeTheFirstWrite() async throws { + let corruptContents = #"{"general": {"fileIconStyle": "colo"# // truncated mid-write + let (store, url) = try makeStore(seed: corruptContents) + + // Loading fell back to defaults, as it must to keep launching. + #expect(store.value(GeneralSettings.self) == GeneralSettings()) + + // The copy must exist *before* any write, not be made on the way out. + let backups = try backupFiles(besides: url) + #expect(backups.count == 1, "the unreadable file was not copied aside") + #expect(try String(contentsOf: try #require(backups.first), encoding: .utf8) == corruptContents) + + // And the original is still where the app expects it, so the copy is a copy, not a move. + #expect(FileManager.default.fileExists(atPath: url.path)) + + // Now let a write land and confirm the preserved copy is untouched by it. + var section = store.value(GeneralSettings.self) + section.fileIconStyle = .monochrome + store.setValue(section) + await settle { (try? Data(contentsOf: url))?.count != corruptContents.utf8.count } + + let after = try backupFiles(besides: url) + #expect(after.count == 1) + #expect(try String(contentsOf: try #require(after.first), encoding: .utf8) == corruptContents) + } + + /// A file that loads but holds **one** undecodable section must be copied aside before the write + /// that replaces that section, and the copy must still hold the user's original text. + /// + /// The scenario is a hand-edit: `"theme": []` is valid JSON but not a `ThemeSettings`, so the + /// section reads as defaults and the next theme change persists those defaults over it. The + /// assertion is deliberately on the *original bytes surviving somewhere*, not on the values read + /// back — asserting defaults would pass against the unprotected implementation too. + @Test + func anUndecodableSectionIsPreservedBeforeTheWriteThatReplacesIt() throws { + let seed = #"{"theme":[],"general":{"fileIconStyle":"monochrome"}}"# + let (store, url) = try makeStore(seed: seed) + + // The rest of the file is fine, so nothing is copied on load. + #expect(store.value(GeneralSettings.self).fileIconStyle == .monochrome) + #expect(try backupFiles(besides: url).isEmpty, "a readable file must not be copied on load") + + // Reading the broken section falls back to defaults, but changes nothing on disk. + #expect(store.value(ThemeSettings.self) == ThemeSettings()) + #expect(try backupFiles(besides: url).isEmpty, "a read is not destructive and needs no copy") + + // The write is the destructive moment; the copy must already exist when it lands. + var theme = store.value(ThemeSettings.self) + theme.matchAppearance = !theme.matchAppearance + store.setValue(theme) + + let backups = try backupFiles(besides: url) + #expect(backups.count == 1, "the undecodable section was replaced with no copy of the original") + #expect(try String(contentsOf: try #require(backups.first), encoding: .utf8) == seed) + } + + /// A second write of the same broken section must not pile up copies — one per store is enough, + /// because the copy is of the whole file. + @Test + func onlyOneCopyIsMadeHoweverManySectionsAreReplaced() throws { + let (store, url) = try makeStore(seed: #"{"theme":[],"terminal":"nope"}"#) + + var theme = store.value(ThemeSettings.self) + theme.matchAppearance = !theme.matchAppearance + store.setValue(theme) + + var terminal = store.value(TerminalSettings.self) + terminal.cursorBlink = !terminal.cursorBlink + store.setValue(terminal) + + #expect(try backupFiles(besides: url).count == 1) + } + + /// A *missing* file is the first-launch case: an empty store is correct and nothing is copied. + @Test + func anAbsentFileIsNotTreatedAsCorruption() throws { + let (store, url) = try makeStore() + + #expect(store.value(GeneralSettings.self) == GeneralSettings()) + #expect(try backupFiles(besides: url).isEmpty, "a first launch must not leave a corrupt-copy") + } + + private func backupFiles(besides url: URL) throws -> [URL] { + try FileManager.default + .contentsOfDirectory(at: url.deletingLastPathComponent(), includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.contains(".corrupt-") } + } + + private func settle(until: () -> Bool) async { + var attempts = 0 + while !until() && attempts < 120 { + attempts += 1 + try? await Task.sleep(for: .milliseconds(50)) + } + } + + /// A section this build has no field for must survive a save, or a disabled extension loses its + /// configuration the first time anything else is written. + @Test + func unknownSectionsSurviveAWrite() async throws { + let (store, url) = try makeStore(seed: #"{"someFutureFeature":{"keep":"me"}}"#) + + var section = store.value(GeneralSettings.self) + section.fileIconStyle = .monochrome + store.setValue(section) + + var attempts = 0 + while attempts < 120 { + attempts += 1 + try? await Task.sleep(for: .milliseconds(50)) + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["general"] != nil { + break + } + } + + let data = try Data(contentsOf: url) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect((object["someFutureFeature"] as? [String: String])?["keep"] == "me") + } +} diff --git a/CodeEditTests/App/RecordingSettingsStore.swift b/CodeEditTests/App/RecordingSettingsStore.swift new file mode 100644 index 0000000000..fe9e264908 --- /dev/null +++ b/CodeEditTests/App/RecordingSettingsStore.swift @@ -0,0 +1,38 @@ +// +// RecordingSettingsStore.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 09/08/2026. +// + +import CodeEditSettings + +/// A `SettingsAccessing` spy: it stores what is written and reads it back, and remembers every +/// write in order. +/// +/// Deliberately *not* a no-op. A discarding double makes a disconnected write path look healthy — +/// which is exactly how the write half shipped untested the first time. +final class RecordingSettingsStore: SettingsAccessing { + private var sections: [String: any SettingsSection] + + /// Every section handed to ``setValue(_:)``, oldest first. + private(set) var writes: [any SettingsSection] = [] + + init(_ sections: [String: any SettingsSection] = [:]) { + self.sections = sections + } + + func value(_ type: S.Type) -> S { + sections[S.settingsKey] as? S ?? S() + } + + func setValue(_ value: S) { + sections[S.settingsKey] = value + writes.append(value) + } + + /// The most recently written section of the given type, or `nil` if none was ever written. + func lastWrite(_ type: S.Type) -> S? { + writes.reversed().compactMap { $0 as? S }.first + } +} diff --git a/CodeEditTests/App/SettingsInstallOrderTests.swift b/CodeEditTests/App/SettingsInstallOrderTests.swift new file mode 100644 index 0000000000..1eeabf482a --- /dev/null +++ b/CodeEditTests/App/SettingsInstallOrderTests.swift @@ -0,0 +1,48 @@ +// +// SettingsInstallOrderTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 10/08/2026. +// + +import Testing +import CodeEditSettings +@testable import CodeEdit + +/// Guards the *consequence* of the settings store reaching `ThemeModel` too late. +/// +/// `ThemeModel.shared` is configured from `AppDependencies.init`, which runs while `AppDelegate`'s +/// non-lazy `dependencies` property is initialized — before any delegate callback. It previously ran +/// in `applicationDidFinishLaunching`, which `application(_:open urls:)` can beat: opening a folder +/// from Finder at cold start builds a workspace window first, and `CodeEditWindowController` +/// force-unwraps `ThemeModel.shared.themes.first!`. On an unconfigured model that array is empty and +/// the app crashes in release. +/// +/// **What this proves and does not.** The ordering itself is structural — it holds because +/// `dependencies` is a stored `let`, not because of anything asserted here — and this test cannot +/// distinguish it from the old ordering, since the app host has fully launched by the time any test +/// runs. What it does guard is the invariant that made the misordering fatal rather than merely +/// wrong: that by the time anything can open a window, the themes are loaded and that force-unwrap +/// is safe. A future change that leaves `ThemeModel` unconfigured at window-open time fails here +/// instead of in a user's crash log. +@MainActor +struct SettingsInstallOrderTests { + + @Test + func themesAreLoadedBeforeAnyWindowCanOpen() { + #expect( + !ThemeModel.shared.themes.isEmpty, + "CodeEditWindowController force-unwraps ThemeModel.shared.themes.first!" + ) + } + + /// The model reads through a real store, not `DefaultSettingsReader`. + /// + /// Asserted through behaviour rather than by inspecting the accessor: a configured model has + /// resolved a selected theme from the store, which a defaults-only reader cannot produce + /// alongside a loaded theme list. + @Test + func themeModelResolvedASelectionFromTheStore() { + #expect(ThemeModel.shared.selectedTheme != nil) + } +} diff --git a/CodeEditTests/App/SettingsSeamInvalidationTests.swift b/CodeEditTests/App/SettingsSeamInvalidationTests.swift new file mode 100644 index 0000000000..da639f333a --- /dev/null +++ b/CodeEditTests/App/SettingsSeamInvalidationTests.swift @@ -0,0 +1,131 @@ +// +// SettingsSeamInvalidationTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 10/08/2026. +// + +import AppKit +import CETerminal +import SwiftUI +import Testing +import CodeEditSettings +@testable import CodeEdit + +/// Covers the settings seam's *invalidation* half: that a change to the store actually re-renders a +/// view reading through `@SettingsValue`. +/// +/// This is the production path end to end — `SettingsInjector` → `\.settingsRevision` + +/// `PersistentSettingsStore` → `@SettingsValue` — not a stand-in. It exists because the read/write tests +/// next door pass just as happily when nothing ever re-renders: they render once. +/// +/// **What it does not prove.** It is a guard on the *outcome*, not on the mechanism: it passes with +/// `\.settingsRevision` injected and without it. Measured while writing it — with the revision +/// removed, with the accessor injected once above the observing view so it is never rewritten, and +/// with the probe behind an `EquatableView` returning `true` — the probe still re-rendered every +/// time. SwiftUI appears to re-evaluate a view holding `@Environment`-backed `DynamicProperty` +/// state whenever an ancestor's body reruns. That is the unspecified behaviour the revision key +/// replaces with a documented one, so this test cannot distinguish the two and should not be read +/// as evidence that it can. +@MainActor +@Suite(.serialized) +struct SettingsSeamInvalidationTests { + + /// A store over a temporary file, so these tests neither read nor overwrite the developer's real + /// `settings.json` — and cannot race any other suite. + private func makeStore() throws -> PersistentSettingsStore { + let directory = URL.temporaryDirectory.appending(path: "SettingsSeam-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return PersistentSettingsStore(settingsURL: directory.appending(path: "settings.json")) + } + + private func setCursorBlink(_ value: Bool, on store: PersistentSettingsStore) { + var section = store.value(TerminalSettings.self) + section.cursorBlink = value + store.setValue(section) + } + /// Records the value observed on every body evaluation, so a *missing* re-render is a visible + /// absence rather than a stale-but-plausible reading. + private final class BodyRecorder { + var observed: [Bool] = [] + } + + private struct RevisionProbe: View { + @SettingsValue(TerminalSettings.self, \.cursorBlink) + private var cursorBlink + + let recorder: BodyRecorder + + var body: some View { + recorder.observed.append(cursorBlink) + return Color.clear + } + } + + /// Hosts `view` without ever ordering the window front — an on-screen window in the shared + /// app-hosted test process destabilised the whole plan. Attaching the hosting view and laying + /// it out is enough to drive SwiftUI's update cycle. + private func host(_ view: V) -> (NSWindow, NSHostingView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 64, height: 64), + styleMask: [.borderless], + backing: .buffered, + defer: true + ) + let hostingView = NSHostingView(rootView: view) + window.contentView = hostingView + hostingView.layoutSubtreeIfNeeded() + return (window, hostingView) + } + + private func settle(_ hostingView: NSHostingView, until: () -> Bool) async { + var attempts = 0 + while !until() && attempts < 200 { + attempts += 1 + hostingView.layoutSubtreeIfNeeded() + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @Test + func changingSettingsBumpsTheRevision() throws { + let store = try makeStore() + let before = store.revision + + setCursorBlink(!store.value(TerminalSettings.self).cursorBlink, on: store) + + #expect(store.revision == before + 1) + } + + /// Guards the **outcome** users care about: a settings change reaches a view reading through the + /// seam. + /// + /// It deliberately does NOT isolate the `\.settingsRevision` mechanism — it passes with and + /// without that key, because SwiftUI also re-evaluates the probe when `SettingsInjector`'s body + /// reruns. Do not read a pass here as evidence the revision signal works; that is + /// ``changingSettingsBumpsTheRevision``'s job. What this catches is the seam silently failing to + /// deliver updated values at all — the failure mode that has recurred most on this branch. + @Test + func settingsChangeReachesAViewThroughTheSeam() async throws { + let store = try makeStore() + setCursorBlink(false, on: store) + + let recorder = BodyRecorder() + let (window, hostingView) = host(SettingsInjector(store: store) { RevisionProbe(recorder: recorder) }) + defer { window.contentView = nil } + + await settle(hostingView) { !recorder.observed.isEmpty } + // The probe reached the real store rather than `DefaultSettingsReader`, and saw the seeded + // value. (Defaults would also read `false` here, which is why the assertion that matters is + // the one below: defaults can never *change*.) + #expect(recorder.observed.last == false) + + setCursorBlink(true, on: store) + + await settle(hostingView) { recorder.observed.last == true } + #expect( + recorder.observed.last == true, + "A settings change did not re-render a view reading through @SettingsValue" + ) + } +} diff --git a/CodeEditTests/App/SettingsValueWriteTests.swift b/CodeEditTests/App/SettingsValueWriteTests.swift new file mode 100644 index 0000000000..a5e28a9750 --- /dev/null +++ b/CodeEditTests/App/SettingsValueWriteTests.swift @@ -0,0 +1,149 @@ +// +// SettingsValueWriteTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 09/08/2026. +// + +import AppKit +import CESourceControl +import SwiftUI +import Testing +import CodeEditSettings +@testable import CodeEdit + +/// Covers `@SettingsValue`'s write half — the `nonmutating set` and the `projectedValue` binding — +/// end to end: seed a section, render a real view, have it write one field, and assert the whole +/// section came back through the accessor with that field changed. +/// +/// **Why these live in the app test target rather than `CodeEditModules/Tests`.** `@SettingsValue` +/// is a `DynamicProperty` that resolves its accessor from `@Environment`, and `@Environment` only +/// resolves inside a view SwiftUI is actually rendering. Constructing the wrapper directly would +/// silently read `DefaultSettingsReader` and assert nothing. Rendering needs an app-hosted runner +/// with a real run loop, which the package test bundles are not (the same constraint that sent +/// `CENotifications`' tests back here). The write-capable double, `RecordingSettingsStore`, is +/// already here too. +@MainActor +@Suite(.serialized) +struct SettingsValueWriteTests { + /// Captures what the view saw when it rendered, so the read side is proven rather than assumed. + private final class ObservedValue { + var value: Bool? + } + + /// Writes through `wrappedValue`'s `nonmutating set`. + private struct WrappedValueProbe: View { + @SettingsValue(SourceControlSettings.self, \.general.sourceControlIsEnabled) + private var sourceControlIsEnabled + + let observed: ObservedValue + + var body: some View { + Color.clear.onAppear { + observed.value = sourceControlIsEnabled + sourceControlIsEnabled = true + } + } + } + + /// Writes through the `Binding` vended by `projectedValue`, the way a `Toggle` would. + private struct ProjectedValueProbe: View { + @SettingsValue(SourceControlSettings.self, \.general.sourceControlIsEnabled) + private var sourceControlIsEnabled + + let observed: ObservedValue + + var body: some View { + Color.clear.onAppear { + let binding: Binding = $sourceControlIsEnabled + observed.value = binding.wrappedValue + binding.wrappedValue = true + } + } + } + + /// A store seeded with two **non-default** fields (both default to `true`), so a view that + /// failed to reach this store would observe `true` and fail the read assertion. + /// + /// `refreshStatusLocally` is the untouched sibling: the probes never write it, so it is what + /// distinguishes a read-modify-write from a section rebuilt at its defaults. + /// + /// A **real** store on a temporary file, not a protocol double: `@SettingsValue` injects through + /// `@EnvironmentObject`, which cannot carry an existential, so a view-level test cannot + /// substitute a recorder. `RecordingSettingsStore` still serves the initializer-injected + /// consumers, which is where the protocol seam still applies. + private func makeSeededStore() -> PersistentSettingsStore { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("settings-\(UUID().uuidString).json") + let store = PersistentSettingsStore(settingsURL: url) + var section = store.value(SourceControlSettings.self) + section.general.sourceControlIsEnabled = false + section.general.refreshStatusLocally = false + store.setValue(section) + return store + } + + /// Hosts `view` long enough for SwiftUI to evaluate its body, then waits until it writes. + /// + /// The window is deliberately **never ordered front**: attaching the hosting view is enough to + /// drive the update cycle, and an on-screen window makes the shared app-hosted test process + /// talk to the window server, which destabilised the whole test plan when this suite ran + /// concurrently with others. + private func render(_ view: some View, until store: PersistentSettingsStore) async { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 64, height: 64), + styleMask: [.borderless], + backing: .buffered, + defer: true + ) + defer { window.contentView = nil } + + let hostingView = NSHostingView(rootView: view) + window.contentView = hostingView + hostingView.layoutSubtreeIfNeeded() + + var attempts = 0 + while store.value(SourceControlSettings.self).general.sourceControlIsEnabled == false + && attempts < 200 { + attempts += 1 + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @Test + func wrappedValueSetterWritesTheWholeSectionBack() async throws { + let store = makeSeededStore() + let observed = ObservedValue() + + await render( + WrappedValueProbe(observed: observed).environmentObject(store), + until: store + ) + + // The view read through the injected store, not through defaults. + #expect(observed.value == false) + + let written = store.value(SourceControlSettings.self) + #expect(written.general.sourceControlIsEnabled == true, "the setter never reached the store") + // Read-modify-write, not replace-with-defaults: the sibling field the probe never touched + // still carries its seeded, non-default value. + #expect(written.general.refreshStatusLocally == false) + } + + @Test + func projectedValueBindingWritesTheWholeSectionBack() async throws { + let store = makeSeededStore() + let observed = ObservedValue() + + await render( + ProjectedValueProbe(observed: observed).environmentObject(store), + until: store + ) + + #expect(observed.value == false) + + let written = store.value(SourceControlSettings.self) + #expect(written.general.sourceControlIsEnabled == true, "the binding never reached the store") + #expect(written.general.refreshStatusLocally == false) + } +} diff --git a/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift b/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift index 0d02c69f9d..24e7a775bc 100644 --- a/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift +++ b/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift @@ -6,29 +6,30 @@ // import XCTest +import CodeEditCore @testable import CodeEdit final class TaskNotificationHandlerTests: XCTestCase { var taskNotificationHandler: TaskNotificationHandler! + var eventBus: EventBus! override func setUp() { super.setUp() - taskNotificationHandler = TaskNotificationHandler() + eventBus = EventBus() + taskNotificationHandler = TaskNotificationHandler(eventBus: eventBus) } override func tearDown() { taskNotificationHandler = nil + eventBus = nil super.tearDown() } func testCreateTask() { let uuid = UUID().uuidString - let userInfo: [String: Any] = [ - "id": uuid, - "action": "create", - "title": "Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: uuid, title: "Task Title")) + )) let testExpectation = XCTestExpectation() DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { @@ -39,19 +40,12 @@ final class TaskNotificationHandlerTests: XCTestCase { } func testCreateTaskWithPriority() { - let task1: [String: Any] = [ - "id": UUID().uuidString, - "action": "create", - "title": "Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: task1) - - let task2: [String: Any] = [ - "id": UUID().uuidString, - "action": "createWithPriority", - "title": "Priority Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: task2) + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: UUID().uuidString, title: "Task Title")) + )) + eventBus.publish(TaskNotificationEvent( + .createWithPriority(TaskNotificationModel(id: UUID().uuidString, title: "Priority Task Title")) + )) let testExpectation = XCTestExpectation() DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { @@ -63,19 +57,12 @@ final class TaskNotificationHandlerTests: XCTestCase { func testUpdateTask() { let uuid = UUID().uuidString - let taskInfo: [String: Any] = [ - "id": uuid, - "action": "create", - "title": "Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: taskInfo) - - let taskUpdateInfo: [String: Any] = [ - "id": uuid, - "action": "update", - "title": "Updated Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: taskUpdateInfo) + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: uuid, title: "Task Title")) + )) + eventBus.publish(TaskNotificationEvent( + .update(id: uuid, title: "Updated Task Title") + )) let testExpectation = XCTestExpectation() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -87,17 +74,10 @@ final class TaskNotificationHandlerTests: XCTestCase { func testDeleteTask() { let uuid = UUID().uuidString - let createUserInfo: [String: Any] = [ - "id": uuid, - "action": "create", - "title": "Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createUserInfo) - let deleteUserInfo: [String: Any] = [ - "id": uuid, - "action": "delete" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteUserInfo) + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: uuid, title: "Task Title")) + )) + eventBus.publish(TaskNotificationEvent(.delete(id: uuid))) let testExpectation = XCTestExpectation() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -109,18 +89,10 @@ final class TaskNotificationHandlerTests: XCTestCase { func testDeleteTaskWithDelay() { let uuid = UUID().uuidString - let createUserInfo: [String: Any] = [ - "id": uuid, - "action": "create", - "title": "Task Title" - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createUserInfo) - let deleteUserInfo: [String: Any] = [ - "id": uuid, - "action": "deleteWithDelay", - "delay": 0.2 - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteUserInfo) + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: uuid, title: "Task Title")) + )) + eventBus.publish(TaskNotificationEvent(.deleteWithDelay(id: uuid, delay: 0.2))) let testExpectation = XCTestExpectation() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -132,4 +104,19 @@ final class TaskNotificationHandlerTests: XCTestCase { } wait(for: [testExpectation], timeout: 1) } + + func testEventForOtherWorkspaceIsIgnored() { + let uuid = UUID().uuidString + eventBus.publish(TaskNotificationEvent( + .create(TaskNotificationModel(id: uuid, title: "Task Title")), + workspace: URL(fileURLWithPath: "/some/other/workspace") + )) + + let testExpectation = XCTestExpectation() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertTrue(self.taskNotificationHandler.notifications.isEmpty) + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + } } diff --git a/CodeEditTests/Features/CEWorkspace/CEWorkspaceFileManagerEventsTests.swift b/CodeEditTests/Features/CEWorkspace/CEWorkspaceFileManagerEventsTests.swift new file mode 100644 index 0000000000..40933e5004 --- /dev/null +++ b/CodeEditTests/Features/CEWorkspace/CEWorkspaceFileManagerEventsTests.swift @@ -0,0 +1,101 @@ +// +// CEWorkspaceFileManagerEventsTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import CEWorkspaceFileManager +import XCTest +import CodeEditCore +@testable import CodeEdit + +final class CEWorkspaceFileManagerEventsTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appending(path: "CEWSFMEvents-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("x".utf8).write(to: directory.appending(path: "changed.swift")) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + func testAppliesGitStatusChangedEvent() throws { + let bus = EventBus() + let manager = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + eventBus: bus + ) + let key = directory.appending(path: "changed.swift").relativePath + XCTAssertNotNil(manager.getFile(key), "file should be cached after init") + + bus.publish(GitStatusChangedEvent(workspaceURL: directory, changed: [key: .modified])) + + let expectation = expectation(description: "status applied") + DispatchQueue.main.async { + XCTAssertEqual(manager.getFile(key)?.gitStatus, .modified) + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + } + + func testClearsStaleGitStatus() throws { + let bus = EventBus() + let manager = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + eventBus: bus + ) + let key = directory.appending(path: "changed.swift").relativePath + manager.getFile(key)?.gitStatus = .modified + + bus.publish(GitStatusChangedEvent(workspaceURL: directory, changed: [:])) + + let expectation = expectation(description: "status cleared") + DispatchQueue.main.async { + XCTAssertNil(manager.getFile(key)?.gitStatus) + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + } + + func testIgnoresEventsForOtherWorkspaces() throws { + let bus = EventBus() + let manager = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + eventBus: bus + ) + let key = directory.appending(path: "changed.swift").relativePath + + bus.publish(GitStatusChangedEvent(workspaceURL: URL(filePath: "/tmp/other"), changed: [key: .modified])) + + let expectation = expectation(description: "no apply") + DispatchQueue.main.async { + XCTAssertNil(manager.getFile(key)?.gitStatus) + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + } + + func testInitPublishesChildrenIndexed() throws { + let bus = EventBus() + var kinds: [String] = [] + let cancellable = bus.subscribe(WorkspaceFileEvent.self) + .sink { event in + if case .childrenIndexed = event.kind { kinds.append("childrenIndexed") } + } + _ = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + eventBus: bus + ) + XCTAssertTrue(kinds.contains("childrenIndexed"), "init loads root children and should emit .childrenIndexed") + cancellable.cancel() + } +} diff --git a/CodeEditTests/Features/CodeEditUI/CodeEditUITests-Bridging-Header.h b/CodeEditTests/Features/CodeEditUI/CodeEditUITests-Bridging-Header.h deleted file mode 100644 index d37372e10d..0000000000 --- a/CodeEditTests/Features/CodeEditUI/CodeEditUITests-Bridging-Header.h +++ /dev/null @@ -1,12 +0,0 @@ -// -// CodeEditUITests-Bridging-Header.h -// CodeEditUITests -// -// Created by Matthijs Eikelenboom on 29/11/2022. -// - -#ifndef CodeEditUITests_Bridging_Header_h -#define CodeEditUITests_Bridging_Header_h - - -#endif /* CodeEditUITests_Bridging_Header_h */ diff --git a/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift b/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift deleted file mode 100644 index 335eef00d4..0000000000 --- a/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// UnitTests.swift -// CodeEditModules/CodeEditUITests -// -// Created by Lukas Pistrol on 19.04.22. -// - -@testable import CodeEdit -import Foundation -import SnapshotTesting -import SwiftUI -import XCTest - -final class CodeEditUIUnitTests: XCTestCase { - - // MARK: Help Button - - func testHelpButtonLight() throws { - let view = HelpButton(action: {}) - let hosting = NSHostingView(rootView: view) - hosting.frame = CGRect(origin: .zero, size: .init(width: 40, height: 40)) - hosting.appearance = .init(named: .aqua) - assertSnapshot(matching: hosting, as: .image(size: .init(width: 40, height: 40))) - } - - func testHelpButtonDark() throws { - let view = HelpButton(action: {}) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .darkAqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 40, height: 40)) - assertSnapshot(matching: hosting, as: .image) - } - - // MARK: Segmented Control - - func testSegmentedControlLight() throws { - let view = SegmentedControl(.constant(0), options: ["Opt1", "Opt2"]) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .aqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 100, height: 30)) - assertSnapshot(matching: hosting, as: .image) - } - - func testSegmentedControlDark() throws { - let view = SegmentedControl(.constant(0), options: ["Opt1", "Opt2"]) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .darkAqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 100, height: 30)) - assertSnapshot(matching: hosting, as: .image) - } - - func testSegmentedControlProminentLight() throws { - let view = SegmentedControl(.constant(0), options: ["Opt1", "Opt2"], prominent: true) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .aqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 100, height: 30)) - assertSnapshot(matching: hosting, as: .image) - } - - func testSegmentedControlProminentDark() throws { - let view = SegmentedControl(.constant(0), options: ["Opt1", "Opt2"], prominent: true) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .darkAqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 100, height: 30)) - assertSnapshot(matching: hosting, as: .image) - } - - // MARK: EffectView - - func testEffectViewLight() throws { - let view = EffectView() - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .aqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 20, height: 20)) - assertSnapshot(matching: hosting, as: .image) - } - - func testEffectViewDark() throws { - let view = EffectView() - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .darkAqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 20, height: 20)) - assertSnapshot(matching: hosting, as: .image) - } - - // MARK: ToolbarBranchPicker - - func testBranchPickerLight() throws { - let view = ToolbarBranchPicker( - workspaceFileManager: nil - ) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .aqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 100, height: 50)) - assertSnapshot(matching: hosting, as: .image) - } - - func testBranchPickerDark() throws { - let view = ToolbarBranchPicker( - workspaceFileManager: nil - ) - let hosting = NSHostingView(rootView: view) - hosting.appearance = .init(named: .darkAqua) - hosting.frame = CGRect(origin: .zero, size: .init(width: 100, height: 50)) - assertSnapshot(matching: hosting, as: .image) - } -} diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testBranchPickerDark.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testBranchPickerDark.1.png deleted file mode 100644 index 33caaf708d..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testBranchPickerDark.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testBranchPickerLight.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testBranchPickerLight.1.png deleted file mode 100644 index 19a4c5737f..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testBranchPickerLight.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testEffectViewDark.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testEffectViewDark.1.png deleted file mode 100644 index f564d2318a..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testEffectViewDark.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testEffectViewLight.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testEffectViewLight.1.png deleted file mode 100644 index 73d8797f58..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testEffectViewLight.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testFontPickerViewDark.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testFontPickerViewDark.1.png deleted file mode 100644 index add2588172..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testFontPickerViewDark.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testFontPickerViewLight.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testFontPickerViewLight.1.png deleted file mode 100644 index 1648835b64..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testFontPickerViewLight.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testHelpButtonDark.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testHelpButtonDark.1.png deleted file mode 100644 index 7ca2a455a5..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testHelpButtonDark.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testHelpButtonLight.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testHelpButtonLight.1.png deleted file mode 100644 index 6887a08759..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testHelpButtonLight.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlDark.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlDark.1.png deleted file mode 100644 index e7c9a3bef0..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlDark.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlLight.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlLight.1.png deleted file mode 100644 index 5b6f692089..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlLight.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlProminentDark.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlProminentDark.1.png deleted file mode 100644 index 57cf8654ea..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlProminentDark.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlProminentLight.1.png b/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlProminentLight.1.png deleted file mode 100644 index bc57bbbbce..0000000000 Binary files a/CodeEditTests/Features/CodeEditUI/__Snapshots__/UnitTests/testSegmentedControlProminentLight.1.png and /dev/null differ diff --git a/CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift index cd0149457a..c0e7d9c55a 100644 --- a/CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift +++ b/CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift @@ -6,6 +6,7 @@ // import XCTest +import CodeEditDocument @testable import CodeEdit diff --git a/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift index b5b8fc0408..929f4f767d 100644 --- a/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift +++ b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift @@ -7,13 +7,64 @@ import Foundation import SwiftUI +import AppKit import Testing +import CodeEditCore +import CodeEditDocument +import CodeEditTextView @testable import CodeEdit @Suite struct CodeFileDocumentTests { let defaultString = "func test() { }" + @MainActor + final class MockDelegate: CodeFileDocumentDelegate { + var openedDocuments: [CodeFileDocument] = [] + var closedURLs: [URL] = [] + var undoRequestedURLs: [URL] = [] + func undoManager(forFile url: URL) -> CEUndoManager? { + undoRequestedURLs.append(url) + return nil + } + func makeWindowContentView(for document: CodeFileDocument) -> NSView { NSView() } + func documentDidOpen(_ document: CodeFileDocument) { openedDocuments.append(document) } + func documentDidClose(at url: URL) { closedURLs.append(url) } + } + + @MainActor + @Test + func delegateConsultedForUndoOnReread() throws { + let mock = MockDelegate() + let previousProvider = CodeFileDocument.delegateProvider + CodeFileDocument.delegateProvider = { mock } + defer { CodeFileDocument.delegateProvider = previousProvider } + + try withCodeFile { codeFile in + // First read happened in `withCodeFile` (content now loaded). A second read + // takes the re-read branch, which consults the delegate for an undo manager. + let data = Data("different contents".utf8) + try codeFile.read(from: data, ofType: "public.source-code") + #expect(codeFile.fileURL != nil && mock.undoRequestedURLs.contains(codeFile.fileURL!)) + } + } + + @MainActor + @Test + func delegateReceivesOpenAndCloseNotifications() throws { + let mock = MockDelegate() + let previousProvider = CodeFileDocument.delegateProvider + CodeFileDocument.delegateProvider = { mock } + defer { CodeFileDocument.delegateProvider = previousProvider } + + try withCodeFile { codeFile in + #expect(mock.openedDocuments.contains { $0 === codeFile }) + let url = codeFile.fileURL + codeFile.close() + #expect(url != nil && mock.closedURLs.contains(url!)) + } + } + private func withFile(_ operation: (URL) throws -> Void) throws { try withTempDir { dir in let fileURL = dir.appending(path: "file.swift") @@ -21,6 +72,7 @@ struct CodeFileDocumentTests { } } + @MainActor private func withCodeFile(_ operation: (CodeFileDocument) throws -> Void) throws { try withFile { fileURL in try defaultString.write(to: fileURL, atomically: true, encoding: .utf8) @@ -29,6 +81,29 @@ struct CodeFileDocumentTests { } } + @MainActor + @Test + func autosavesInPlaceReflectsProvider() { + let original = CodeFileDocument.isAutoSaveOnProvider + defer { CodeFileDocument.isAutoSaveOnProvider = original } + + CodeFileDocument.isAutoSaveOnProvider = { true } + #expect(CodeFileDocument.autosavesInPlace == true) + + CodeFileDocument.isAutoSaveOnProvider = { false } + #expect(CodeFileDocument.autosavesInPlace == false) + } + + @MainActor + @Test + func indentOptionOverrideUsesCoreType() { + let codeFile = CodeFileDocument() + codeFile.indentOption = CodeEditCore.IndentOption(indentType: .spaces, spaceCount: 2) + #expect(codeFile.indentOption?.indentType == .spaces) + #expect(codeFile.indentOption?.spaceCount == 2) + } + + @MainActor @Test func testLoadUTF8Encoding() throws { try withFile { fileURL in @@ -43,6 +118,7 @@ struct CodeFileDocumentTests { } } + @MainActor @Test func testWriteUTF8Encoding() throws { try withFile { fileURL in @@ -68,6 +144,7 @@ struct CodeFileDocumentTests { } } + @MainActor @Test func ignoresExternalUpdatesWithOutstandingChanges() throws { try withCodeFile { codeFile in @@ -86,18 +163,29 @@ struct CodeFileDocumentTests { } } + // Deliberately NOT @MainActor: `presentedItemDidChange` is a file-presenter callback that + // performs `DispatchQueue.main.sync` internally, so it must be invoked off the main thread + // (as NSFileCoordinator does in production). The document itself is created on main. @Test - func loadsExternalUpdatesWithNoOutstandingChanges() throws { - try withCodeFile { codeFile in + func loadsExternalUpdatesWithNoOutstandingChanges() async throws { + try await withTempDir { dir in + let fileURL = dir.appending(path: "file.swift") + try defaultString.write(to: fileURL, atomically: true, encoding: .utf8) + let codeFile = try await MainActor.run { + try CodeFileDocument(contentsOf: fileURL, ofType: "public.source-code") + } + // Update the modification date - try "different contents".write(to: codeFile.fileURL!, atomically: true, encoding: .utf8) + try "different contents".write(to: fileURL, atomically: true, encoding: .utf8) - // Tell the file the disk representation changed + // Tell the file the disk representation changed (off-main, like a real presenter callback) codeFile.presentedItemDidChange() // The file should have reloaded (it was clean) - #expect(codeFile.content?.string == "different contents") - #expect(codeFile.isDocumentEdited == false) + await MainActor.run { + #expect(codeFile.content?.string == "different contents") + #expect(codeFile.isDocumentEdited == false) + } } } } diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift index 34209faea0..27039ab0fe 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift @@ -6,20 +6,21 @@ // import XCTest +import CodeEditCore +@testable import CESearch @testable import CodeEdit @MainActor final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_body_length private var directory: URL! private var files: [CEWorkspaceFile] = [] - private var mockWorkspace: WorkspaceDocument! - private var searchState: WorkspaceDocument.SearchState! + private var searchState: SearchState! private var folder1File: CEWorkspaceFile? private var folder2File: CEWorkspaceFile? // MARK: - Setup - /// A mock WorkspaceDocument is created + /// A mock Workspace is created /// 3 mock files are added to the index /// which will be removed in the teardown function override func setUp() async throws { @@ -34,9 +35,6 @@ final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_bod try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - mockWorkspace = try WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") - searchState = mockWorkspace.searchState - // Add a few files let folder1 = directory.appending(path: "Folder 2") folder1File = CEWorkspaceFile(url: folder1) @@ -64,7 +62,8 @@ final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_bod files[1].parent = folder1File files[2].parent = folder2File - mockWorkspace.searchState?.addProjectToIndex() + // SearchState indexes the workspace as part of its initializer. + searchState = SearchState(workspaceURL: directory, eventBus: EventBus()) // NOTE: This is a temporary solution. In the future, a file watcher should track file updates // and trigger an index update. diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift index 61ddfb2bbd..3419976903 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift @@ -6,16 +6,17 @@ // import XCTest +import CodeEditCore +@testable import CESearch @testable import CodeEdit final class FindTests: XCTestCase { private var directory: URL! private var files: [CEWorkspaceFile] = [] - private var mockWorkspace: WorkspaceDocument! - private var searchState: WorkspaceDocument.SearchState! + private var searchState: SearchState! // MARK: - Setup - /// A mock WorkspaceDocument is created + /// A mock Workspace is created /// 3 mock files are added to the index /// which will be removed in the teardown function override func setUp() async throws { @@ -30,9 +31,6 @@ final class FindTests: XCTestCase { try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - mockWorkspace = try await WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") - searchState = await mockWorkspace.searchState - // Add a few files let folder1 = directory.appending(path: "Folder 2") let folder2 = directory.appending(path: "Longer Folder With Some 💯 Special Chars ⁉️") @@ -60,7 +58,8 @@ final class FindTests: XCTestCase { files[1].parent = parent1 files[2].parent = parent2 - await mockWorkspace.searchState?.addProjectToIndex() + // SearchState indexes the workspace as part of its initializer. + searchState = SearchState(workspaceURL: directory, eventBus: EventBus()) // The following code also tests whether the workspace is indexed correctly // Wait until the index is up to date and flushed @@ -137,7 +136,7 @@ final class FindTests: XCTestCase { XCTAssertEqual(searchState.getRegexPattern(query), "\\b@\\(test\\. !\\*#Query\\b") } - /// Tests the search functionality of the `WorkspaceDocument.SearchState` and `SearchIndexer`. + /// Tests the search functionality of the `SearchState` and `SearchIndexer`. func testSearch() async { await searchState.search("Ipsum") // Wait for the first search expectation to be fulfilled diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+IndexTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+IndexTests.swift index e37039e0aa..f423892dba 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+IndexTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+IndexTests.swift @@ -6,19 +6,20 @@ // import XCTest +import CodeEditCore +@testable import CESearch @testable import CodeEdit -final class WorkspaceDocumentIndexTests: XCTestCase { +final class WorkspaceIndexTests: XCTestCase { private var directory: URL! private var files: [CEWorkspaceFile] = [] - private var mockWorkspace: WorkspaceDocument! - private var searchState: WorkspaceDocument.SearchState! + private var searchState: SearchState! private var folder1File: CEWorkspaceFile? private var folder2File: CEWorkspaceFile? // MARK: - Setup - /// A mock WorkspaceDocument is created + /// A mock Workspace is created /// 3 mock files are added to the index /// which will be removed in the teardown function override func setUp() async throws { @@ -33,9 +34,6 @@ final class WorkspaceDocumentIndexTests: XCTestCase { try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - mockWorkspace = try await WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") - searchState = await mockWorkspace.searchState - // Add a few files let folder1 = directory.appending(path: "Folder 2") folder1File = CEWorkspaceFile(url: folder1) @@ -63,7 +61,8 @@ final class WorkspaceDocumentIndexTests: XCTestCase { files[1].parent = folder1File files[2].parent = folder2File - await mockWorkspace.searchState?.addProjectToIndex() + // SearchState indexes the workspace as part of its initializer. + searchState = SearchState(workspaceURL: directory, eventBus: EventBus()) // The following code also tests whether the workspace is indexed correctly // Wait until the index is up to date and flushed diff --git a/CodeEditTests/Features/Editor/AppActiveCursorStateTests.swift b/CodeEditTests/Features/Editor/AppActiveCursorStateTests.swift new file mode 100644 index 0000000000..0440f6226c --- /dev/null +++ b/CodeEditTests/Features/Editor/AppActiveCursorStateTests.swift @@ -0,0 +1,59 @@ +// +// AppActiveCursorStateTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Combine +import Testing +import CodeEditCore +import CEWorkspaceFileManager +@testable import CEEditor +import CodeEditSourceEditor +@testable import CodeEdit + +@Suite +struct AppActiveCursorStateTests { + @MainActor + @Test + func reflectsAndPublishesActiveTabCursorPositions() { + let editorManager = EditorManager() + let fileA = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/a.swift")) + editorManager.activeEditor.openTab(file: fileA) + + let state = AppActiveCursorState(editorManager: editorManager) + + // The freshly opened tab seeds a default cursor at line 1, column 1. + #expect(state.cursorPositions.first?.line == 1) + #expect(state.cursorPositions.first?.column == 1) + + var received: [[EditorCursorPosition]] = [] + let cancellable = state.cursorPositionsPublisher.sink { received.append($0) } + + // Drive a new cursor position on the active tab. + let tab = editorManager.activeEditor.selectedTab + tab?.cursorPositions = [CursorPosition(line: 5, column: 3)] + cancellable.cancel() + + #expect(received.last?.first?.line == 5) + #expect(received.last?.first?.column == 3) + } + + @MainActor + @Test + func mapsRangeAndForwardsLinesInRangeSafelyWithoutTextView() { + let editorManager = EditorManager() + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/a.swift")) + editorManager.activeEditor.openTab(file: file) + + let state = AppActiveCursorState(editorManager: editorManager) + let tab = editorManager.activeEditor.selectedTab + tab?.cursorPositions = [CursorPosition(range: NSRange(location: 10, length: 4))] + + #expect(state.cursorPositions.first?.range == NSRange(location: 10, length: 4)) + // No live text view is attached in a unit test, so the forwarded query returns 0. + #expect(state.linesInRange(NSRange(location: 10, length: 4)) == 0) + } +} diff --git a/CodeEditTests/Features/Editor/AppActiveEditorStateTests.swift b/CodeEditTests/Features/Editor/AppActiveEditorStateTests.swift new file mode 100644 index 0000000000..159c7436c6 --- /dev/null +++ b/CodeEditTests/Features/Editor/AppActiveEditorStateTests.swift @@ -0,0 +1,39 @@ +// +// AppActiveEditorStateTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Combine +import Testing +import CodeEditCore +import CEWorkspaceFileManager +@testable import CEEditor +@testable import CodeEdit + +@Suite +struct AppActiveEditorStateTests { + @MainActor + @Test + func reflectsAndPublishesActiveFile() { + let editorManager = EditorManager() + let fileA = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/a.swift")) + editorManager.activeEditor.openTab(file: fileA) + + let state = AppActiveEditorState(editorManager: editorManager) + #expect(state.selectedFile?.url == fileA.url) + + var received: [URL?] = [] + let cancellable = state.selectedFilePublisher.sink { received.append($0?.url) } + + let fileB = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/b.swift")) + editorManager.activeEditor.openTab(file: fileB) + cancellable.cancel() + + // Current value replayed on subscribe (fileA) then fileB after the switch. + #expect(received.first == fileA.url) + #expect(received.last == fileB.url) + } +} diff --git a/CodeEditTests/Features/Editor/AppFileEditorOverridesTests.swift b/CodeEditTests/Features/Editor/AppFileEditorOverridesTests.swift new file mode 100644 index 0000000000..f27d7c4471 --- /dev/null +++ b/CodeEditTests/Features/Editor/AppFileEditorOverridesTests.swift @@ -0,0 +1,86 @@ +// +// AppFileEditorOverridesTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Testing +import CodeEditCore +import CEWorkspaceFileManager +import CodeEditDocument +@testable import CEEditor +import CodeEditLanguages +@testable import CodeEdit + +@Suite +struct AppFileEditorOverridesTests { + @MainActor + @Test + func readsAndWritesDocumentOverrides() { + let editorManager = EditorManager() + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/a.swift")) + let document = CodeFileDocument() + editorManager.documents.setDocument(document, for: file) + + let overrides = AppFileEditorOverrides(editorManager: editorManager) + + // No overrides set yet. + #expect(overrides.overrides(for: file).indentOption == nil) + + // Writes propagate to the document. + let indent = IndentOption(indentType: .spaces, spaceCount: 8) + overrides.setIndentOption(indent, for: file) + overrides.setDefaultTabWidth(3, for: file) + overrides.setWrapLines(true, for: file) + #expect(document.indentOption == indent) + #expect(document.defaultTabWidth == 3) + #expect(document.wrapLines == true) + + // Reads reflect the document. + let values = overrides.overrides(for: file) + #expect(values.indentOption == indent) + #expect(values.defaultTabWidth == 3) + #expect(values.wrapLines == true) + + _ = document // registry holds documents weakly; keep alive for the test + } + + @MainActor + @Test + func languageIdRoundTrips() { + let editorManager = EditorManager() + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/a.swift")) + let document = CodeFileDocument() + editorManager.documents.setDocument(document, for: file) + let overrides = AppFileEditorOverrides(editorManager: editorManager) + + guard let swift = CodeLanguage.allLanguages.first(where: { $0.id.rawValue == "swift" }) else { + Issue.record("swift language not found in allLanguages") + return + } + + overrides.setLanguageId(swift.id.rawValue, for: file) + #expect(document.language?.id.rawValue == "swift") + #expect(overrides.overrides(for: file).languageId == "swift") + + overrides.setLanguageId(nil, for: file) + #expect(document.language == nil) + #expect(overrides.overrides(for: file).languageId == nil) + + _ = document + } + + @MainActor + @Test + func noDocumentYieldsEmptyOverridesAndNoOpWrites() { + let editorManager = EditorManager() + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/none.swift")) + let overrides = AppFileEditorOverrides(editorManager: editorManager) + + #expect(overrides.overrides(for: file) == FileEditorOverrideValues()) + overrides.setIndentOption(IndentOption(indentType: .tab), for: file) // no crash + #expect(overrides.overrides(for: file).indentOption == nil) + } +} diff --git a/CodeEditTests/Features/Editor/DocumentRegistryTests.swift b/CodeEditTests/Features/Editor/DocumentRegistryTests.swift new file mode 100644 index 0000000000..330b1c7aac --- /dev/null +++ b/CodeEditTests/Features/Editor/DocumentRegistryTests.swift @@ -0,0 +1,68 @@ +// +// DocumentRegistryTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +import XCTest +import CodeEditDocument +import Combine +import CodeEditCore +@testable import CodeEdit +@testable import CEEditor + +final class DocumentRegistryTests: XCTestCase { + private func makeFile(_ path: String = "/tmp/reg-\(UUID().uuidString).swift") -> CEWorkspaceFile { + CEWorkspaceFile(url: URL(filePath: path)) + } + + func testSetAndGetRoundTrip() { + let registry = DocumentRegistry() + let file = makeFile() + let doc = CodeFileDocument() + registry.setDocument(doc, for: file) + XCTAssertIdentical(registry.document(for: file), doc) + } + + func testSetNilClears() { + let registry = DocumentRegistry() + let file = makeFile() + registry.setDocument(CodeFileDocument(), for: file) + registry.setDocument(nil, for: file) + XCTAssertNil(registry.document(for: file)) + } + + func testUnknownFileReturnsNil() { + let registry = DocumentRegistry() + XCTAssertNil(registry.document(for: makeFile())) + } + + func testPublisherEmitsOnSet() { + let registry = DocumentRegistry() + let file = makeFile() + var received: [Bool] = [] // whether each emission was non-nil + var cancellables = Set() + registry.documentPublisher(for: file) + .sink { received.append($0 != nil) } + .store(in: &cancellables) + + let doc = CodeFileDocument() + registry.setDocument(doc, for: file) + registry.setDocument(nil, for: file) + + XCTAssertEqual(received, [true, false]) + } + + func testStorageIsWeak() { + let registry = DocumentRegistry() + let file = makeFile() + autoreleasepool { + let doc = CodeFileDocument() + registry.setDocument(doc, for: file) + XCTAssertNotNil(registry.document(for: file)) + } + // No strong owner remains → the weak ref must be nil. + XCTAssertNil(registry.document(for: file), "registry must not retain the document") + } +} diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift index a97363fc68..25c476f0a0 100644 --- a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation @testable import CodeEdit +@testable import CEEditor @Suite struct EditorStateRestorationTests { diff --git a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift b/CodeEditTests/Features/Editor/UndoManagerRegistryTests.swift similarity index 83% rename from CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift rename to CodeEditTests/Features/Editor/UndoManagerRegistryTests.swift index cfa9619aaf..ee13265b69 100644 --- a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift +++ b/CodeEditTests/Features/Editor/UndoManagerRegistryTests.swift @@ -1,19 +1,21 @@ // -// UndoManagerRegistrationTests.swift +// UndoManagerRegistryTests.swift // CodeEditTests // // Created by Khan Winter on 7/3/25. // @testable import CodeEdit +@testable import CEEditor import Testing +import CodeEditCore import Foundation import CodeEditTextView @MainActor @Suite -struct UndoManagerRegistrationTests { - let registrar = UndoManagerRegistration() +struct UndoManagerRegistryTests { + let registrar = UndoManagerRegistry() let file = CEWorkspaceFile(url: URL(filePath: "/fake/dir/file.txt")) let textView = TextView(string: "hello world") diff --git a/CodeEditTests/Features/LSP/LSPServiceDocumentObjectsTests.swift b/CodeEditTests/Features/LSP/LSPServiceDocumentObjectsTests.swift new file mode 100644 index 0000000000..f6735445f2 --- /dev/null +++ b/CodeEditTests/Features/LSP/LSPServiceDocumentObjectsTests.swift @@ -0,0 +1,50 @@ +// +// LSPServiceDocumentObjectsTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 06/07/2026. +// + +@testable import CELSP +import XCTest +import CodeEditDocument +import CodeEditSettings +@testable import CodeEdit + +@MainActor +final class LSPServiceDocumentObjectsTests: XCTestCase { + private func makeService() -> LSPService { LSPService(settingsReader: SnapshotSettingsReader([:])) } + + private func makeDocument(path: String) throws -> CodeFileDocument { + let url = FileManager.default.temporaryDirectory + .appending(path: "lsp-objs-\(UUID().uuidString)-\(path)") + try Data("x".utf8).write(to: url) + return try CodeFileDocument(contentsOf: url, ofType: "public.swift-source") + } + + func testSameURIReturnsSameInstance() throws { + let service = makeService() + let doc = try makeDocument(path: "a.swift") + let first = service.languageServerObjects(for: doc) + let second = service.languageServerObjects(for: doc) + XCTAssertTrue(first.textCoordinator === second.textCoordinator) + } + + func testDifferentURIsReturnDistinctInstances() throws { + let service = makeService() + let docA = try makeDocument(path: "a.swift") + let docB = try makeDocument(path: "b.swift") + let objectsA = service.languageServerObjects(for: docA) + let objectsB = service.languageServerObjects(for: docB) + XCTAssertFalse(objectsA.textCoordinator === objectsB.textCoordinator) + } + + func testRemoveDropsStoredInstance() throws { + let service = makeService() + let doc = try makeDocument(path: "a.swift") + let first = service.languageServerObjects(for: doc) + service.removeLanguageServerObjects(for: doc.languageServerURI!) + let afterRemove = service.languageServerObjects(for: doc) + XCTAssertFalse(first.textCoordinator === afterRemove.textCoordinator) + } +} diff --git a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift index 7112ccec8a..4c5d568f8a 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift @@ -5,7 +5,11 @@ // Created by Khan Winter on 9/9/24. // +@testable import CELSP +import CEWorkspaceFileManager +import CodeEditDocument import XCTest +import CodeEditCore import CodeEditTextView import CodeEditSourceEditor import LanguageClient @@ -25,8 +29,25 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { var tempTestDir: URL! + /// A dedicated dependency graph for this test class. `NSApp.delegate` is SwiftUI's + /// adaptor wrapper (not our `AppDelegate`), so the host graph isn't reachable from + /// tests; an isolated graph is cleaner anyway. The static + /// `CodeFileDocument.delegateProvider` is repointed at it in `setUp` so document + /// lifecycle notifications reach THIS graph's `LSPService`, and restored in `tearDown`. + private var testDependencies: AppDependencies! + private var previousDelegateProvider: (() -> CodeFileDocumentDelegate?)! + + @MainActor var appDependencies: AppDependencies { testDependencies } + override func setUp() { continueAfterFailure = false + // XCTest invokes setUp on the main thread; AppDependencies is main-actor isolated. + MainActor.assumeIsolated { + let dependencies = AppDependencies() + testDependencies = dependencies + previousDelegateProvider = CodeFileDocument.delegateProvider + CodeFileDocument.delegateProvider = { dependencies.codeFileDocumentDelegate } + } do { let tempDir = FileManager.default.temporaryDirectory.appending( path: "codeedit-lsp-tests" @@ -43,6 +64,10 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { } override func tearDown() { + MainActor.assumeIsolated { + CodeFileDocument.delegateProvider = previousDelegateProvider + testDependencies = nil + } do { try FileManager.default.removeItem(at: tempTestDir) } catch { @@ -50,6 +75,7 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { } } + @MainActor func makeTestServer() async throws -> (connection: BufferingServerConnection, server: LanguageServerType) { let bufferingConnection = BufferingServerConnection() var capabilities = ServerCapabilities() @@ -72,22 +98,28 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { lspPid: -1, serverCapabilities: capabilities, rootPath: tempTestDir, - logContainer: LanguageServerLogContainer(language: .swift) + logContainer: LanguageServerLogContainer(language: .swift), + provideObjects: { self.appDependencies.lspService.languageServerObjects(for: $0) }, + clearObjects: { self.appDependencies.lspService.removeLanguageServerObjects(for: $0) } ) _ = try await server.lspInstance.initializeIfNeeded() return (connection: bufferingConnection, server: server) } - func makeTestWorkspace() throws -> (WorkspaceDocument, CEWorkspaceFileManager) { - let workspace = WorkspaceDocument() - try workspace.read(from: tempTestDir, ofType: "") - guard let fileManager = workspace.workspaceFileManager else { - XCTFail("No File Manager") - fatalError("No File Manager") // never runs + @MainActor + func makeTestWorkspace() throws -> (Workspace, CEWorkspaceFileManager) { + let windowManager = appDependencies.workspaceWindowManager + try windowManager.openWorkspace(at: tempTestDir) + guard let workspace = windowManager.openWorkspaces.first(where: { + $0.fileURL.standardizedFileURL.path() == tempTestDir.standardizedFileURL.path() + }) else { + XCTFail("Workspace was not registered with the window manager") + fatalError("Workspace was not registered with the window manager") // never runs } - return (workspace, fileManager) + return (workspace, workspace.workspaceFileManager) } + @MainActor func openCodeFile( for server: LanguageServerType, connection: BufferingServerConnection, @@ -146,12 +178,11 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { let (connection, server) = try await makeTestServer() // This service should receive the didOpen/didClose notifications - let lspService = ServiceContainer.resolve(.singleton, LSPService.self) - await MainActor.run { lspService?.languageClients[.init(.swift, tempTestDir.path() + "/")] = server } + let lspService = appDependencies.lspService + lspService.languageClients[.init(.swift, tempTestDir.path() + "/")] = server - // Set up workspace + // Set up workspace. Registers it with the workspace window manager. let (workspace, fileManager) = try makeTestWorkspace() - CodeEditDocumentController.shared.addDocument(workspace) // Add a CEWorkspaceFile _ = try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") @@ -166,8 +197,8 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { withContentsOf: file.url, ofType: "public.swift-source" ) - file.fileDocument = codeFile - CodeEditDocumentController.shared.addDocument(codeFile) + workspace.editorManager.setDocument(codeFile, for: file) + NSDocumentController.shared.addDocument(codeFile) await waitForClientState( ( @@ -232,13 +263,14 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { let (connection, server) = try await makeTestServer() // Create a CodeFileDocument to test with, attach it to the workspace and file let codeFile = try await openCodeFile(for: server, connection: connection, file: file, syncOption: option) - XCTAssertNotNil(codeFile.languageServerObjects.textCoordinator.languageServer) - codeFile.languageServerObjects.textCoordinator.setUpUpdatesTask() + let lspObjects = appDependencies.lspService.languageServerObjects(for: codeFile) + XCTAssertNotNil(lspObjects.textCoordinator.languageServer) + lspObjects.textCoordinator.setUpUpdatesTask() codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) let textView = TextView(string: "") textView.setTextStorage(codeFile.content!) - textView.delegate = codeFile.languageServerObjects.textCoordinator + textView.delegate = lspObjects.textCoordinator textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "") @@ -289,14 +321,15 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { // Set up test server let (connection, server) = try await makeTestServer() let codeFile = try await openCodeFile(for: server, connection: connection, file: file, syncOption: option) + let lspObjects = appDependencies.lspService.languageServerObjects(for: codeFile) - XCTAssertNotNil(codeFile.languageServerObjects.textCoordinator.languageServer) - codeFile.languageServerObjects.textCoordinator.setUpUpdatesTask() + XCTAssertNotNil(lspObjects.textCoordinator.languageServer) + lspObjects.textCoordinator.setUpUpdatesTask() codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) let textView = TextView(string: "") textView.setTextStorage(codeFile.content!) - textView.delegate = codeFile.languageServerObjects.textCoordinator + textView.delegate = lspObjects.textCoordinator textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "") textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift index 9b7738a7d8..1dfdab8fad 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift @@ -5,6 +5,7 @@ // Created by Khan Winter on 2/12/25. // +@testable import CELSP import XCTest import CodeEditTextView import CodeEditSourceEditor @@ -14,16 +15,19 @@ import LanguageServerProtocol @testable import CodeEdit +@MainActor final class LanguageServerDocumentObjectsTests: XCTestCase { - final class MockDocumentType: LanguageServerDocument { + @MainActor + final class MockDocumentType: @preconcurrency LanguageServerDocument { var content: NSTextStorage? var languageServerURI: String? - var languageServerObjects: LanguageServerDocumentObjects + /// Test-local store (the protocol no longer requires it; `LSPService` owns it in production). + /// The server's `provideObjects`/`clearObjects` closures are wired to this in `setUp`. + var languageServerObjects = LanguageServerDocumentObjects() init() { self.content = NSTextStorage(string: "hello world") self.languageServerURI = "/test/file/path" - self.languageServerObjects = .init() } func getLanguage() -> CodeLanguage { @@ -42,6 +46,8 @@ final class LanguageServerDocumentObjectsTests: XCTestCase { var capabilities = ServerCapabilities() capabilities.textDocumentSync = .optionA(.init(openClose: true, change: .full)) capabilities.semanticTokensProvider = .optionA(.init(legend: .init(tokenTypes: [], tokenModifiers: []))) + let document = MockDocumentType() + self.document = document server = LanguageServerType( languageId: .swift, binary: .init(execPath: "", args: [], env: nil), @@ -52,10 +58,11 @@ final class LanguageServerDocumentObjectsTests: XCTestCase { lspPid: -1, serverCapabilities: capabilities, rootPath: URL(fileURLWithPath: ""), - logContainer: LanguageServerLogContainer(language: .swift) + logContainer: LanguageServerLogContainer(language: .swift), + provideObjects: { $0.languageServerObjects }, + clearObjects: { [weak document] _ in document?.languageServerObjects = .init() } ) _ = try await server.lspInstance.initializeIfNeeded() - document = MockDocumentType() } // MARK: - Tests diff --git a/CodeEditTests/Features/LSP/Registry.swift b/CodeEditTests/Features/LSP/Registry.swift index fcbc4df851..c34b29f53a 100644 --- a/CodeEditTests/Features/LSP/Registry.swift +++ b/CodeEditTests/Features/LSP/Registry.swift @@ -5,14 +5,26 @@ // Created by Abe Malla on 2/2/25. // +@testable import CELSP import Testing import Foundation +import CodeEditCore +import CodeEditSettings +import ShellClient @testable import CodeEdit @MainActor @Suite() struct RegistryTests { - var registry: RegistryManager = RegistryManager() + var registry: RegistryManager = RegistryManager( + eventBus: EventBus(), + errorNotifier: NoOpErrorNotifier(), + shellClient: ShellClient(), + settingsAccessor: RecordingSettingsStore(), + // The same path the manager used to read off the settings singleton, so these tests keep + // exercising the real install location. + installPath: SettingsLocation.baseURL.appending(path: "Language Servers") + ) // MARK: - Download Tests @@ -20,7 +32,7 @@ struct RegistryTests { func registryDownload() async throws { await registry.downloadRegistryItems() - #expect(registry.downloadError == nil) + #expect(registry.viewState.downloadError == nil) let registryJsonPath = registry.installPath.appending(path: "registry.json") let checksumPath = registry.installPath.appending(path: "checksums.txt") diff --git a/CodeEditTests/Features/LSP/RegistryManagerPersistenceTests.swift b/CodeEditTests/Features/LSP/RegistryManagerPersistenceTests.swift new file mode 100644 index 0000000000..b047b8eb93 --- /dev/null +++ b/CodeEditTests/Features/LSP/RegistryManagerPersistenceTests.swift @@ -0,0 +1,122 @@ +// +// RegistryManagerPersistenceTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 09/08/2026. +// + +@testable import CELSP +import Testing +import Foundation +import CodeEditCore +import CodeEditSettings +import ShellClient +@testable import CodeEdit + +/// Asserts that every `RegistryManager` mutation of `installedLanguageServers` is written back +/// through the settings seam, with the value the user would expect to find after a relaunch. +/// +/// These assertions are all about *non-default* state — an empty registry, or one that never +/// reaches the store, fails them. That is the point: a no-op writer must not pass. +@MainActor +@Suite +struct RegistryManagerPersistenceTests { + private static let packageName = "codeedit-registry-persistence-test-package" + + private func makeStore(installed: [String: LanguageServerSettings.Installed]) -> RecordingSettingsStore { + var section = LanguageServerSettings() + section.installedLanguageServers = installed + return RecordingSettingsStore([LanguageServerSettings.settingsKey: section]) + } + + private func makeManager(store: RecordingSettingsStore) -> RegistryManager { + RegistryManager( + eventBus: EventBus(), + errorNotifier: NoOpErrorNotifier(), + shellClient: ShellClient(), + settingsAccessor: store, + installPath: URL.temporaryDirectory.appending(path: "RegistryManagerPersistenceTests") + ) + } + + private func installed(_ isEnabled: Bool) -> LanguageServerSettings.Installed { + .init(packageName: Self.packageName, isEnabled: isEnabled, version: "1.2.3") + } + + @Test + func seedingReadsThroughButDoesNotPersist() throws { + let store = makeStore(installed: [Self.packageName: installed(true)]) + let manager = makeManager(store: store) + + // The seed is read from the store, not invented. + #expect(manager.installedLanguageServers[Self.packageName]?.version == "1.2.3") + // Assigning inside `init` does not fire `didSet`, so construction must write nothing. + #expect(store.writes.isEmpty) + } + + @Test + func setPackageEnabledPersists() throws { + let store = makeStore(installed: [Self.packageName: installed(true)]) + let manager = makeManager(store: store) + + manager.setPackageEnabled(packageName: Self.packageName, enabled: false) + + let written = try #require(store.lastWrite(LanguageServerSettings.self)) + #expect(written.installedLanguageServers[Self.packageName]?.isEnabled == false) + // And the store now answers with it, i.e. a relaunch would see the change. + #expect(store.value(LanguageServerSettings.self).installedLanguageServers[Self.packageName]?.isEnabled == false) + } + + @Test + func removeLanguageServerPersists() async throws { + let store = makeStore(installed: [Self.packageName: installed(true)]) + let manager = makeManager(store: store) + + // No directory exists for this name, so the manager takes its "already gone" path and + // still has to persist the removal. + #expect(!FileManager.default.fileExists(atPath: manager.installPath.appending(path: Self.packageName).path)) + + try await manager.removeLanguageServer(packageName: Self.packageName) + + let written = try #require(store.lastWrite(LanguageServerSettings.self)) + #expect(written.installedLanguageServers[Self.packageName] == nil) + #expect(store.value(LanguageServerSettings.self).installedLanguageServers.isEmpty) + } + + @Test + func successfulInstallPersists() async throws { + let store = makeStore(installed: [:]) + let manager = makeManager(store: store) + + // A zero-step operation completes immediately without shelling out, which is enough to + // reach the completion branch that records the installed package. + let package = RegistryItem( + name: Self.packageName, + description: "", + homepage: "", + licenses: [], + languages: [], + categories: [], + source: .init(id: "pkg:npm/\(Self.packageName)@4.5.6", asset: nil, build: nil, versionOverrides: nil), + bin: nil + ) + let operation = PackageManagerInstallOperation(package: package, steps: [], shellClient: ShellClient()) + + try manager.startInstallation(operation: operation) + + // The install runs in a detached-from-us `Task`; wait for the write rather than sleeping a + // fixed amount. + var attempts = 0 + while store.lastWrite(LanguageServerSettings.self) == nil && attempts < 200 { + attempts += 1 + try await Task.sleep(for: .milliseconds(10)) + } + + let written = try #require( + store.lastWrite(LanguageServerSettings.self), + "Install completed without persisting the registry" + ) + #expect(written.installedLanguageServers[Self.packageName]?.version == "4.5.6") + #expect(written.installedLanguageServers[Self.packageName]?.isEnabled == true) + } +} diff --git a/CodeEditTests/Features/NavigatorArea/ProjectNavigatorViewModelTests.swift b/CodeEditTests/Features/NavigatorArea/ProjectNavigatorViewModelTests.swift new file mode 100644 index 0000000000..7a7caa54b2 --- /dev/null +++ b/CodeEditTests/Features/NavigatorArea/ProjectNavigatorViewModelTests.swift @@ -0,0 +1,39 @@ +// +// ProjectNavigatorViewModelTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 03/07/2026. +// + +import XCTest +@testable import CodeEdit + +@MainActor +final class ProjectNavigatorViewModelTests: XCTestCase { + var viewModel: ProjectNavigatorViewModel! + + override func setUp() { + super.setUp() + viewModel = ProjectNavigatorViewModel() + } + + override func tearDown() { + viewModel = nil + super.tearDown() + } + + func testDefaults() { + XCTAssertEqual(viewModel.navigatorFilter, "") + XCTAssertTrue(viewModel.sortFoldersOnTop) + XCTAssertFalse(viewModel.sourceControlFilter) + } + + func testFilterMutationPublishes() { + let expectation = expectation(description: "objectWillChange fires") + let cancellable = viewModel.objectWillChange.sink { expectation.fulfill() } + viewModel.navigatorFilter = "abc" + wait(for: [expectation], timeout: 1) + cancellable.cancel() + XCTAssertEqual(viewModel.navigatorFilter, "abc") + } +} diff --git a/CodeEditTests/Features/Notifications/NotificationPanelViewModelTests.swift b/CodeEditTests/Features/Notifications/NotificationPanelViewModelTests.swift new file mode 100644 index 0000000000..e791b18ed2 --- /dev/null +++ b/CodeEditTests/Features/Notifications/NotificationPanelViewModelTests.swift @@ -0,0 +1,103 @@ +// +// NotificationPanelViewModelTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 02/07/2026. +// + +import XCTest +import CodeEditCore +@testable import CENotifications +@testable import CodeEdit + +@MainActor +final class NotificationPanelViewModelTests: XCTestCase { + var eventBus: EventBus! + var notificationManager: (any NotificationManaging)! + var viewModel: NotificationPanelViewModel! + + override func setUp() { + super.setUp() + // Fresh manager and bus so the view model doesn't preload notifications from earlier tests. + // Manager and view model must share one bus: posts flow manager → bus → view model. + eventBus = EventBus() + notificationManager = NotificationManager(eventBus: eventBus) + viewModel = NotificationPanelViewModel(notificationManager: notificationManager, eventBus: eventBus) + } + + override func tearDown() { + viewModel = nil + notificationManager = nil + eventBus = nil + super.tearDown() + } + + func testNotificationAddedAppearsInPanel() async throws { + notificationManager.post( + iconSymbol: "bell", + title: "Test Notification", + description: "A notification for testing", + actionButtonTitle: "OK", + action: {} + ) + + // Allow the Combine republish from manager to view model to propagate. + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertEqual(viewModel.activeNotifications.first?.title, "Test Notification") + } + + func testNotificationDismissedRemovedFromPanel() async throws { + notificationManager.post( + iconSymbol: "bell", + title: "Test Notification", + description: "A notification for testing", + actionButtonTitle: "OK", + action: {} + ) + + try await Task.sleep(for: .milliseconds(200)) + + let notification = try XCTUnwrap( + notificationManager.notifications.first, + "Notification was never added to the manager" + ) + notificationManager.dismissNotification(notification) + + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertTrue(viewModel.activeNotifications.isEmpty) + XCTAssertTrue(notificationManager.notifications.isEmpty) + } + + func testUnreadCountRepublishedToViewModel() async throws { + notificationManager.post( + iconSymbol: "bell", + title: "First", + description: "A notification for testing", + actionButtonTitle: "OK", + action: {} + ) + notificationManager.post( + iconSymbol: "bell", + title: "Second", + description: "A notification for testing", + actionButtonTitle: "OK", + action: {} + ) + + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertEqual(viewModel.unreadCount, 2) + + let notification = try XCTUnwrap( + notificationManager.notifications.first, + "Notifications were never added to the manager" + ) + notificationManager.markAsRead(notification) + + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertEqual(viewModel.unreadCount, 1) + } +} diff --git a/CodeEditTests/Features/QuickActions/QuickActionsViewModelTests.swift b/CodeEditTests/Features/QuickActions/QuickActionsViewModelTests.swift new file mode 100644 index 0000000000..d26d9fd140 --- /dev/null +++ b/CodeEditTests/Features/QuickActions/QuickActionsViewModelTests.swift @@ -0,0 +1,74 @@ +// +// QuickActionsViewModelTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 02/07/2026. +// + +import XCTest +import CodeEditCore +@testable import CodeEdit + +private final class MockCommandManager: CommandManaging { + var commands: [Command] = [ + Command(id: "open.drawer", title: "Toggle Utility Area", closureWrapper: {}), + Command(id: "quick.open", title: "Quick Open", closureWrapper: {}), + Command(id: "toggle.navigator", title: "Toggle Navigator", closureWrapper: {}) + ] + + func addCommand(name: String, title: String, id: String, command: @escaping () -> Void) { + commands.append(Command(id: id, title: title, closureWrapper: command)) + } + + func executeCommand(_ id: String) { + commands.first { $0.id == id }?.closureWrapper() + } +} + +final class QuickActionsViewModelTests: XCTestCase { + private var viewModel: QuickActionsViewModel! + + override func setUp() { + super.setUp() + viewModel = QuickActionsViewModel(commandManager: MockCommandManager()) + } + + override func tearDown() { + viewModel = nil + super.tearDown() + } + + func testFetchMatchingCommandsFiltersCaseInsensitively() { + viewModel.fetchMatchingCommands(val: "toggle") + + XCTAssertEqual( + viewModel.filteredCommands.map(\.title).sorted(), + ["Toggle Navigator", "Toggle Utility Area"] + ) + XCTAssertEqual(viewModel.selected?.id, viewModel.filteredCommands.first?.id) + } + + func testFetchMatchingCommandsWithEmptyQueryReturnsAllCommands() { + viewModel.fetchMatchingCommands(val: "") + + XCTAssertEqual(viewModel.filteredCommands.count, 3) + } + + func testFetchMatchingCommandsWithoutMatchReturnsNothing() { + viewModel.fetchMatchingCommands(val: "nonexistent") + + XCTAssertTrue(viewModel.filteredCommands.isEmpty) + XCTAssertNil(viewModel.selected) + } + + func testResetClearsQueryAndSelectionAndReseedsCommands() { + viewModel.fetchMatchingCommands(val: "quick") + viewModel.commandQuery = "quick" + + viewModel.reset() + + XCTAssertEqual(viewModel.commandQuery, "") + XCTAssertNil(viewModel.selected) + XCTAssertEqual(viewModel.filteredCommands.count, 3) + } +} diff --git a/CodeEditTests/Features/Search/FuzzySearch/FuzzySearchTests.swift b/CodeEditTests/Features/Search/FuzzySearch/FuzzySearchTests.swift deleted file mode 100644 index 9a9ba41781..0000000000 --- a/CodeEditTests/Features/Search/FuzzySearch/FuzzySearchTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// FuzzySearchTests.swift -// CodeEditTests -// -// Created by Tommy Ludwig on 03.02.24. -// - -import XCTest -@testable import CodeEdit - -final class FuzzySearchTests: XCTestCase { - func testNormalisation() { - XCTAssertEqual("ü".normalise()[0].normalisedContent, "u") - XCTAssertEqual("ñ".normalise()[0].normalisedContent, "n") - XCTAssertEqual("é".normalise()[0].normalisedContent, "e") - } - - func testFuzzyMatchWeight() { - guard let url = URL(string: "path/ContentView.swift") else { - XCTFail("URL could not be created") - return - } - XCTAssert(url.fuzzyMatch(query: "CV").weight > 0) - XCTAssert(url.fuzzyMatch(query: "conv").weight > 0) - XCTAssert(url.fuzzyMatch(query: "sw").weight > 0) - XCTAssert(url.fuzzyMatch(query: "path").weight == 0) - } - - func testFuzzyMatchRange() { - guard let url = URL(string: "path/ContentView.swift") else { - XCTFail("URL could not be created") - return - } - let range = url.fuzzyMatch(query: "ConVie").matchedParts - - XCTAssertEqual(url.lastPathComponent[range[0]], "Con") - XCTAssertEqual(url.lastPathComponent[range[1]], "Vie") - } - - func testFuzzySearch() async { - let urls = [ - URL(string: "FuzzySearchable.swift")!, - URL(string: "ContentView.swift")!, - URL(string: "FuzzyMatch.swift")! - ] - let fuzzyMatchResult = await urls.fuzzySearch(query: "mch").map { - $0.item - } - XCTAssertEqual(fuzzyMatchResult[0].lastPathComponent, "FuzzyMatch.swift") - - let contentViewResult = await urls.fuzzySearch(query: "CV").map { - $0.item - } - XCTAssertEqual(contentViewResult[0].lastPathComponent, "ContentView.swift") - - let fuzzySearchableResult = await urls.fuzzySearch(query: "seable").map { - $0.item - } - XCTAssertEqual(fuzzySearchableResult[0].lastPathComponent, "FuzzySearchable.swift") - - let swiftResults = await urls.fuzzySearch(query: "swif").map { - $0.item - } - XCTAssertEqual(swiftResults.count, 3) - } -} diff --git a/CodeEditTests/Features/SourceControl/GitClientTests.swift b/CodeEditTests/Features/SourceControl/GitClientTests.swift index 5f288102e6..8d4f5ee7ba 100644 --- a/CodeEditTests/Features/SourceControl/GitClientTests.swift +++ b/CodeEditTests/Features/SourceControl/GitClientTests.swift @@ -5,7 +5,9 @@ // Created by Khan Winter on 9/11/25. // +@testable import CESourceControl import Testing +import ShellClient @testable import CodeEdit @Suite @@ -15,7 +17,7 @@ struct GitClientTests { try withTempDir { dirURL in // swiftlint:disable:next line_length let string = "1 .M N... 100644 100644 100644 eaef31cfa2a22418c00d7477da0b7151d122681e eaef31cfa2a22418c00d7477da0b7151d122681e CodeEdit/Features/SourceControl/Client/GitClient+Status.swift\01 AM N... 000000 100644 100644 0000000000000000000000000000000000000000 e0f5ce250b32cf6610a284b7a33ac114079f5159 CodeEditTests/Features/SourceControl/GitClientTests.swift\0" - let client = GitClient(directoryURL: dirURL, shellClient: .live()) + let client = GitClient(directoryURL: dirURL, shellClient: ShellClient()) let status = try client.parseStatusString(string) #expect(status.changedFiles.count == 2) diff --git a/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift b/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift index dda948257c..6434053944 100644 --- a/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift +++ b/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift @@ -6,7 +6,9 @@ // import Testing +import CodeEditCore @testable import CodeEdit +@testable import CETerminal @MainActor @Suite(.serialized) @@ -20,7 +22,7 @@ class CEActiveTaskTests { command: "echo $STATE", environmentVariables: [CETask.EnvironmentVariable(key: "STATE", value: "Testing")] ) - activeTask = CEActiveTask(task: task) + activeTask = CEActiveTask(task: task, eventBus: EventBus()) } @Test @@ -50,7 +52,12 @@ class CEActiveTaskTests { @Test(arguments: [Shell.zsh, Shell.bash]) func testHandleProcessFinished(_ shell: Shell) async throws { - task.command = "aNon-existentCommand" + // CETask is a value type, so build a fresh active task around the failing command + // rather than mutating `task` after `activeTask` already copied it. + let activeTask = CEActiveTask( + task: CETask(name: "Test Task", command: "aNon-existentCommand"), + eventBus: EventBus() + ) activeTask.run(workspaceURL: nil, shell: shell) activeTask.waitForExit() diff --git a/CodeEditTests/Features/Tasks/TaskManagerTests.swift b/CodeEditTests/Features/Tasks/TaskManagerTests.swift index be384edd30..36ec8168bc 100644 --- a/CodeEditTests/Features/Tasks/TaskManagerTests.swift +++ b/CodeEditTests/Features/Tasks/TaskManagerTests.swift @@ -6,32 +6,43 @@ // import Foundation +import Combine +import CodeEditSettings import Testing +import CodeEditCore @testable import CodeEdit +@testable import CETerminal + +/// In-memory stand-in for `CEWorkspaceSettings`: task configuration without disk I/O. +final class TasksConfigurationStub: TasksConfigurationProviding { + @Published var tasks: [CETask] = [] + var tasksPublisher: AnyPublisher<[CETask], Never> { $tasks.eraseToAnyPublisher() } +} @MainActor @Suite(.serialized) class TaskManagerTests { var taskManager: TaskManager! - var mockWorkspaceSettings: CEWorkspaceSettingsData! + var tasksConfiguration: TasksConfigurationStub! init() throws { - let workspaceSettings = try JSONDecoder().decode(CEWorkspaceSettingsData.self, from: Data("{}".utf8)) - mockWorkspaceSettings = workspaceSettings - taskManager = TaskManager(workspaceSettings: mockWorkspaceSettings, workspaceURL: nil) + tasksConfiguration = TasksConfigurationStub() + taskManager = TaskManager(tasksConfiguration: tasksConfiguration, workspaceURL: nil, eventBus: EventBus()) } func testInitialization() { #expect(taskManager != nil) - #expect(taskManager.availableTasks == mockWorkspaceSettings.tasks) + #expect(taskManager.availableTasks == tasksConfiguration.tasks) } @Test func executeTaskInZsh() async throws { - Settings.shared.preferences.terminal.shell = .zsh + // Deliberately configures no shell. The shell preference reaches a task through + // `TerminalEmulatorView`'s `@SettingsValue`, which this headless test never constructs, so + // the singleton write that used to stand here changed nothing about what ran. let task = CETask(name: "Test Task", command: "echo 'Hello World'") - mockWorkspaceSettings.tasks.append(task) + tasksConfiguration.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() @@ -47,10 +58,10 @@ class TaskManagerTests { @Test func executeTaskInBash() async throws { - Settings.shared.preferences.terminal.shell = .bash + // See `executeTaskInZsh` — the shell preference never reached this path. let task = CETask(name: "Test Task", command: "echo 'Hello World'") - mockWorkspaceSettings.tasks.append(task) + tasksConfiguration.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() @@ -67,7 +78,7 @@ class TaskManagerTests { @Test(.disabled("Not sure why but tasks run in shells seem to never receive signals.")) func terminateSelectedTask() async throws { let task = CETask(name: "Test Task", command: "sleep 10") - mockWorkspaceSettings.tasks.append(task) + tasksConfiguration.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() @@ -93,7 +104,7 @@ class TaskManagerTests { @Test(.disabled("Not sure why but tasks run in shells seem to never receive signals.")) func suspendAndResumeTask() async throws { let task = CETask(name: "Test Task", command: "sleep 5") - mockWorkspaceSettings.tasks.append(task) + tasksConfiguration.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() diff --git a/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift b/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift index 2b160b829c..cd6e6de8d2 100644 --- a/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift +++ b/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import XCTest @testable import CodeEdit +@testable import CETerminal final class ShellIntegrationTests: XCTestCase { func testBash() throws { diff --git a/CodeEditTests/Features/UtilityArea/UtilityAreaViewModelTests.swift b/CodeEditTests/Features/UtilityArea/UtilityAreaViewModelTests.swift index 5406cb58f4..b8a0b664f0 100644 --- a/CodeEditTests/Features/UtilityArea/UtilityAreaViewModelTests.swift +++ b/CodeEditTests/Features/UtilityArea/UtilityAreaViewModelTests.swift @@ -7,6 +7,7 @@ import XCTest @testable import CodeEdit +import CETerminal final class UtilityAreaViewModelTests: XCTestCase { var model: UtilityAreaViewModel! diff --git a/CodeEditTests/Features/Welcome/RecentProjectsTests.swift b/CodeEditTests/Features/Welcome/RecentProjectsTests.swift index 19fc6ccb8c..6b654d1ef4 100644 --- a/CodeEditTests/Features/Welcome/RecentProjectsTests.swift +++ b/CodeEditTests/Features/Welcome/RecentProjectsTests.swift @@ -1,5 +1,5 @@ // -// RecentsStoreTests.swift +// RecentProjectsTests.swift // CodeEditTests // // Created by Khan Winter on 5/27/25. diff --git a/CodeEditTests/Features/Workspace/AppFileRelocatorTests.swift b/CodeEditTests/Features/Workspace/AppFileRelocatorTests.swift new file mode 100644 index 0000000000..41d212de7a --- /dev/null +++ b/CodeEditTests/Features/Workspace/AppFileRelocatorTests.swift @@ -0,0 +1,49 @@ +// +// AppFileRelocatorTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Testing +import CodeEditCore +@testable import CodeEdit + +@Suite +struct AppFileRelocatorTests { + @MainActor + final class MockWindowManager: WorkspaceWindowManaging { + var queried: [URL] = [] + var openWorkspaces: [Workspace] = [] + func openWorkspace(at url: URL) throws {} + func closeWorkspace(_ workspace: Workspace) {} + func workspace(containing url: URL) -> Workspace? { + queried.append(url) + return nil + } + func openFileInWorkspace(url: URL, asTemporary: Bool) -> Bool { false } + func openDocumentFromPanel() {} + func newDocumentFromPanel() {} + func openDocument(at url: URL, onCompletion: @escaping () -> Void) {} + func openDocumentWithDialog( + canChooseFiles: Bool, + canChooseDirectories: Bool, + onDialogPresented: (() -> Void)?, + onCancel: (() -> Void)? + ) {} + } + + @MainActor + @Test + func returnsNilAndQueriesByURLWhenWorkspaceUnresolved() throws { + let mock = MockWindowManager() + let relocator = AppFileRelocator(windowManager: mock) + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/a.swift")) + + let result = try relocator.relocate(file: file, to: URL(fileURLWithPath: "/tmp/b.swift")) + + #expect(result == nil) + #expect(mock.queried == [file.url]) + } +} diff --git a/CodeEditTests/Features/Workspace/AppWorkspaceNavigatorTests.swift b/CodeEditTests/Features/Workspace/AppWorkspaceNavigatorTests.swift new file mode 100644 index 0000000000..ba8be53ae3 --- /dev/null +++ b/CodeEditTests/Features/Workspace/AppWorkspaceNavigatorTests.swift @@ -0,0 +1,126 @@ +// +// AppWorkspaceNavigatorTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom. +// + +import Foundation +import Testing +import Combine +import CodeEditCore +@testable import CodeEdit +@testable import CEEditor + +@Suite +struct AppWorkspaceNavigatorTests { + @MainActor + final class MockWindowManager: WorkspaceWindowManaging { + var opened: [(url: URL, asTemporary: Bool)] = [] + var openWorkspaces: [Workspace] = [] + var stubbedWorkspace: Workspace? + func openWorkspace(at url: URL) throws {} + func closeWorkspace(_ workspace: Workspace) {} + func workspace(containing url: URL) -> Workspace? { stubbedWorkspace } + func openFileInWorkspace(url: URL, asTemporary: Bool) -> Bool { + opened.append((url, asTemporary)) + return true + } + func openDocumentFromPanel() {} + func newDocumentFromPanel() {} + func openDocument(at url: URL, onCompletion: @escaping () -> Void) {} + func openDocumentWithDialog( + canChooseFiles: Bool, + canChooseDirectories: Bool, + onDialogPresented: (() -> Void)?, + onCancel: (() -> Void)? + ) {} + } + + @MainActor + @Test + func openDelegatesToWindowManagerWithTemporaryFlag() { + let mock = MockWindowManager() + let navigator = AppWorkspaceNavigator(windowManager: mock) + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/example.swift")) + + navigator.open(file: file, asTemporary: true) + + #expect(mock.opened.count == 1) + #expect(mock.opened.first?.url == file.url) + #expect(mock.opened.first?.asTemporary == true) + } + + @MainActor + @Test + func openFileAtURLResolvesThroughWorkspaceFileManagerAndPreservesTemporaryFlag() throws { + let workspace = try TestWorkspaceFactory.make() + let fileURL = workspace.fileURL.appending(path: "example.swift") + try "// example".write(to: fileURL, atomically: true, encoding: .utf8) + + let mock = MockWindowManager() + mock.stubbedWorkspace = workspace + let navigator = AppWorkspaceNavigator(windowManager: mock) + + navigator.open(fileAt: fileURL, asTemporary: true) + + #expect(mock.opened.count == 1) + #expect(mock.opened.first?.url == fileURL) + #expect(mock.opened.first?.asTemporary == true) + } + + /// Regression test for the case the old unsorted `workspace(containing:)` probe could miss: + /// `open(fileAt:)` must still open the file via `openFileInWorkspace` (the sorted, + /// nearest-workspace path) even when the unsorted probe would have returned nil. + @MainActor + @Test + func openFileAtURLOpensEvenWhenWorkspaceContainingProbeMisses() throws { + let mock = MockWindowManager() + // Deliberately leave `stubbedWorkspace` nil so `workspace(containing:)` returns nil, + // simulating the unsorted probe missing a URL that the sorted path would still resolve. + let navigator = AppWorkspaceNavigator(windowManager: mock) + let fileURL = URL(fileURLWithPath: "/tmp/unmatched-by-probe.swift") + + navigator.open(fileAt: fileURL, asTemporary: false) + + #expect(mock.opened.count == 1) + #expect(mock.opened.first?.url == fileURL) + #expect(mock.opened.first?.asTemporary == false) + } + + @MainActor + @Test + func revealSendsRevealRequestOnCorrectWorkspace() throws { + let workspace = try TestWorkspaceFactory.make() + let mock = MockWindowManager() + mock.stubbedWorkspace = workspace + let navigator = AppWorkspaceNavigator(windowManager: mock) + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/example.swift")) + + var revealed: [CEWorkspaceFile] = [] + let cancellable = workspace.revealRequests.sink { revealed.append($0) } + defer { cancellable.cancel() } + + navigator.reveal(file: file) + + #expect(revealed.count == 1) + #expect(revealed.first === file) + } + + @MainActor + @Test + func closeTabClosesFileInEditorLayout() throws { + let workspace = try TestWorkspaceFactory.make() + let editorManager = workspace.editorManager + let mock = MockWindowManager() + mock.stubbedWorkspace = workspace + let navigator = AppWorkspaceNavigator(windowManager: mock) + let file = CEWorkspaceFile(url: URL(fileURLWithPath: "/tmp/example.swift")) + + editorManager.activeEditor.openTab(file: file, asTemporary: false) + #expect(editorManager.activeEditor.tabs.contains(where: { $0.file == file })) + + navigator.closeTab(file: file) + #expect(!editorManager.activeEditor.tabs.contains(where: { $0.file == file })) + } +} diff --git a/CodeEditTests/Features/Workspace/FileExtensionVisibilityTests.swift b/CodeEditTests/Features/Workspace/FileExtensionVisibilityTests.swift new file mode 100644 index 0000000000..f8234009ee --- /dev/null +++ b/CodeEditTests/Features/Workspace/FileExtensionVisibilityTests.swift @@ -0,0 +1,84 @@ +// +// FileExtensionVisibilityTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 05/08/2026. +// + +import XCTest +import CodeEditCore +import CodeEditSettings +@testable import CodeEdit + +/// Covers `CEWorkspaceFile.labelFileName(_:)`, which had no tests despite driving every +/// Project Navigator row label. +/// +/// It used to mutate the `Settings.shared` singleton and restore it in `tearDown`; the settings it +/// reads are now a parameter, so each case builds the exact `GeneralSettings` it means and no +/// process-wide state is touched. +final class FileExtensionVisibilityTests: XCTestCase { + + private func settings( + _ visibility: GeneralSettings.FileExtensionsVisibility, + shown: [String] = [], + hidden: [String] = [] + ) -> GeneralSettings { + var settings = GeneralSettings() + settings.fileExtensionsVisibility = visibility + settings.shownFileExtensions.extensions = shown + settings.hiddenFileExtensions.extensions = hidden + return settings + } + + private func label(_ filename: String, _ settings: GeneralSettings) -> String { + CEWorkspaceFile(url: URL(filePath: "/tmp/\(filename)")).labelFileName(settings) + } + + func testShowAllKeepsEveryExtension() { + let settings = settings(.showAll) + XCTAssertEqual(label("notes.txt", settings), "notes.txt") + XCTAssertEqual(label("Model.swift", settings), "Model.swift") + } + + func testHideAllStripsEveryExtension() { + let settings = settings(.hideAll) + XCTAssertEqual(label("notes.txt", settings), "notes") + XCTAssertEqual(label("Model.swift", settings), "Model") + } + + func testShowOnlyKeepsListedAndStripsTheRest() { + let settings = settings(.showOnly, shown: ["swift"]) + XCTAssertEqual(label("Model.swift", settings), "Model.swift") + XCTAssertEqual(label("notes.txt", settings), "notes") + } + + func testHideOnlyStripsListedAndKeepsTheRest() { + let settings = settings(.hideOnly, hidden: ["swift"]) + XCTAssertEqual(label("Model.swift", settings), "Model") + XCTAssertEqual(label("notes.txt", settings), "notes.txt") + } + + /// Regression for 2715e319. Matching used to compare `FileType.rawValue`, whose value for + /// `.txt` was the string `"text"` — so entering `txt` never matched anything. + func testTxtIsMatchableByItsRealExtension() { + let settings = settings(.showOnly, shown: ["txt"]) + XCTAssertEqual(label("notes.txt", settings), "notes.txt") + XCTAssertEqual(label("Model.swift", settings), "Model") + } + + /// Regression for 2715e319. Extensions absent from the old `FileType` enum all fell back to + /// `.txt` and reported themselves as `"text"`, so the preference could never match them. + func testExtensionsAbsentFromTheOldEnumAreMatchable() { + let settings = settings(.hideOnly, hidden: ["toml"]) + XCTAssertEqual(label("Config.toml", settings), "Config") + XCTAssertEqual(label("notes.txt", settings), "notes.txt") + } + + func testExtensionlessNamesAreUnaffected() { + for mode in [GeneralSettings.FileExtensionsVisibility.hideAll, .showAll] { + let settings = settings(mode) + XCTAssertEqual(label("LICENSE", settings), "LICENSE", "mode \(mode)") + XCTAssertEqual(label("Makefile", settings), "Makefile", "mode \(mode)") + } + } +} diff --git a/CodeEditTests/Features/Documents/Mocks/NSHapticFeedbackPerformerMock.swift b/CodeEditTests/Features/Workspace/Mocks/NSHapticFeedbackPerformerMock.swift similarity index 100% rename from CodeEditTests/Features/Documents/Mocks/NSHapticFeedbackPerformerMock.swift rename to CodeEditTests/Features/Workspace/Mocks/NSHapticFeedbackPerformerMock.swift diff --git a/CodeEditTests/Features/Workspace/WorkspaceLifecycleTests.swift b/CodeEditTests/Features/Workspace/WorkspaceLifecycleTests.swift new file mode 100644 index 0000000000..6ca7b4d041 --- /dev/null +++ b/CodeEditTests/Features/Workspace/WorkspaceLifecycleTests.swift @@ -0,0 +1,35 @@ +// +// WorkspaceLifecycleTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +import XCTest +import CEEditor +import CETerminal +@testable import CodeEdit + +@MainActor +final class WorkspaceLifecycleTests: XCTestCase { + /// tearDown() no longer nils members; this guards that nothing retains the + /// workspace graph after close (a strong manager→Workspace back-reference, + /// or a manager self-cycle via a `sink` without `[weak self]`). + func testWorkspaceAndManagersDeallocateAfterTearDown() throws { + weak var weakWorkspace: Workspace? + weak var weakEditorManager: EditorManager? + weak var weakTaskManager: TaskManager? + + try autoreleasepool { + let workspace = try TestWorkspaceFactory.make() + weakWorkspace = workspace + weakEditorManager = workspace.editorManager + weakTaskManager = workspace.taskManager + workspace.tearDown() + } + + XCTAssertNil(weakWorkspace, "Workspace leaked after tearDown") + XCTAssertNil(weakEditorManager, "EditorManager leaked after tearDown") + XCTAssertNil(weakTaskManager, "TaskManager leaked after tearDown") + } +} diff --git a/CodeEditTests/Features/Documents/DocumentsUnitTests.swift b/CodeEditTests/Features/Workspace/WorkspaceWindowTests.swift similarity index 87% rename from CodeEditTests/Features/Documents/DocumentsUnitTests.swift rename to CodeEditTests/Features/Workspace/WorkspaceWindowTests.swift index 39f47901b8..0fbe52ae76 100644 --- a/CodeEditTests/Features/Documents/DocumentsUnitTests.swift +++ b/CodeEditTests/Features/Workspace/WorkspaceWindowTests.swift @@ -1,21 +1,27 @@ // -// DocumentsUnitTests.swift +// WorkspaceWindowTests.swift // CodeEditTests // // Created by YAPRYNTSEV Aleksey on 31.12.2022. // import XCTest +import CodeEditCore +import ShellClient +import CENotifications +import CESourceControl +import CESearch +import CETerminal @testable import CodeEdit @MainActor -final class DocumentsUnitTests: XCTestCase { +final class WorkspaceWindowTests: XCTestCase { // Properties private var splitViewController: CodeEditSplitViewController! private var hapticFeedbackPerformerMock: NSHapticFeedbackPerformerMock! private var navigatorViewModel: NavigatorAreaViewModel! private var window: NSWindow! - private var workspace = WorkspaceDocument() + private var workspace: Workspace! // MARK: - Lifecycle @@ -23,12 +29,21 @@ final class DocumentsUnitTests: XCTestCase { super.setUp() hapticFeedbackPerformerMock = NSHapticFeedbackPerformerMock() navigatorViewModel = .init() - workspace.taskManager = TaskManager(workspaceSettings: CEWorkspaceSettingsData(), workspaceURL: nil) + // swiftlint:disable:next force_try + workspace = try! TestWorkspaceFactory.make() window = NSWindow() + let eventBus = EventBus() splitViewController = .init( workspace: workspace, navigatorViewModel: navigatorViewModel, windowRef: window, + dependencies: AppDependencies(), + statusBarViewModel: StatusBarViewModel(), + utilityAreaModel: UtilityAreaViewModel(), + notificationPanel: NotificationPanelViewModel( + notificationManager: NotificationManager(eventBus: eventBus), + eventBus: eventBus + ), hapticPerformer: hapticFeedbackPerformerMock ) splitViewController.viewDidLoad() diff --git a/CodeEditTests/Helpers/TestWorkspaceFactory.swift b/CodeEditTests/Helpers/TestWorkspaceFactory.swift new file mode 100644 index 0000000000..408a14a8d7 --- /dev/null +++ b/CodeEditTests/Helpers/TestWorkspaceFactory.swift @@ -0,0 +1,22 @@ +// +// TestWorkspaceFactory.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 13/07/2026. +// + +import Foundation +@testable import CodeEdit + +/// Builds a real `Workspace` via `WorkspaceFactory.make` on a fresh temp directory. +/// `AppDependencies` is all-lazy, so a per-test instance is cheap. +@MainActor +enum TestWorkspaceFactory { + static func make(dependencies: AppDependencies? = nil) throws -> Workspace { + let dependencies = dependencies ?? AppDependencies() + let dir = URL(filePath: NSTemporaryDirectory()) + .appending(path: "workspace-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return WorkspaceFactory.make(url: dir, dependencies: dependencies) + } +} diff --git a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift index 2fb01159fd..778b54f503 100644 --- a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift +++ b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift @@ -1,10 +1,12 @@ // -// UnitTests.swift +// CEWorkspaceFileManagerTests.swift // CodeEditModules/WorkspaceClient // // Created by Marco Carnevali on 16/03/22. // +import CEWorkspaceFileManager import Combine +import CodeEditCore import Foundation import XCTest @testable import CodeEdit @@ -13,7 +15,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let typeOfExtensions = ["json", "txt", "swift", "js", "py", "md"] var directory: URL! - class DummyObserver: CEWorkspaceFileManagerObserver { + class DummyObserver: WorkspaceFileObserver { var completion: (() -> Void)? init(completion: @escaping () -> Void) { @@ -54,7 +56,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let client = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], - sourceControlManager: nil + eventBus: EventBus() ) // Compare to flattened files - 1 cause root is in there @@ -66,7 +68,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let client = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], - sourceControlManager: nil + eventBus: EventBus() ) let newFile = generateRandomFiles(amount: 1)[0] @@ -118,7 +120,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], - sourceControlManager: nil + eventBus: EventBus() ) XCTAssert(fileManager.getFile(testFileURL.path()) == nil) @@ -134,11 +136,11 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], - sourceControlManager: nil + eventBus: EventBus() ) XCTAssert(fileManager.getFile(testFileURL.path()) != nil) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) - try fileManager.delete(file: CEWorkspaceFile(url: testFileURL), confirmDelete: false) + try fileManager.delete(file: CEWorkspaceFile(url: testFileURL)) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == false) } @@ -150,7 +152,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], - sourceControlManager: nil + eventBus: EventBus() ) XCTAssert(fileManager.getFile(testFileURL.path()) != nil) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) @@ -164,7 +166,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], - sourceControlManager: nil + eventBus: EventBus() ) // This will throw if unsuccessful. diff --git a/CodeEditTests/Utils/FuzzyMatching/FuzzyMatchTests.swift b/CodeEditTests/Utils/FuzzyMatching/FuzzyMatchTests.swift new file mode 100644 index 0000000000..ac4dd1665a --- /dev/null +++ b/CodeEditTests/Utils/FuzzyMatching/FuzzyMatchTests.swift @@ -0,0 +1,37 @@ +// +// FuzzyMatchTests.swift +// CodeEditTests +// +// Created by Tommy Ludwig on 03.02.24. +// + +import XCTest +import CodeEditCore +@testable import CodeEdit + +/// Tests the app's `URL: FuzzyMatchable` conformance (OpenQuickly). The fuzzy-match +/// algorithm itself is covered in CodeEditCore's `CodeEditCoreTests/FuzzyMatchTests`. +final class FuzzyMatchTests: XCTestCase { + func testFuzzyMatchWeightUsesFileNameOnly() { + guard let url = URL(string: "path/ContentView.swift") else { + XCTFail("URL could not be created") + return + } + XCTAssert(url.fuzzyMatch(query: "CV").weight > 0) + XCTAssert(url.fuzzyMatch(query: "conv").weight > 0) + XCTAssert(url.fuzzyMatch(query: "sw").weight > 0) + // Directory components must not match — only the file name is searchable. + XCTAssert(url.fuzzyMatch(query: "path").weight == 0) + } + + func testFuzzyMatchRangesIndexIntoFileName() { + guard let url = URL(string: "path/ContentView.swift") else { + XCTFail("URL could not be created") + return + } + let range = url.fuzzyMatch(query: "ConVie").matchedParts + + XCTAssertEqual(url.lastPathComponent[range[0]], "Con") + XCTAssertEqual(url.lastPathComponent[range[1]], "Vie") + } +} diff --git a/CodeEditTests/Utils/UnitTests_Extensions.swift b/CodeEditTests/Utils/UnitTests_Extensions.swift index 9118f1a1f6..e2fc62163d 100644 --- a/CodeEditTests/Utils/UnitTests_Extensions.swift +++ b/CodeEditTests/Utils/UnitTests_Extensions.swift @@ -1,5 +1,5 @@ // -// UnitTests.swift +// UnitTests_Extensions.swift // CodeEditModules/CodeEditUtilsTests // // Created by Lukas Pistrol on 01.05.22. @@ -8,6 +8,8 @@ import Foundation import SwiftUI import XCTest +import CodeEditCore +import CodeEditSettings @testable import CodeEdit final class CodeEditUtilsExtensionsUnitTests: XCTestCase { @@ -56,83 +58,6 @@ final class CodeEditUtilsExtensionsUnitTests: XCTestCase { XCTAssertEqual(alpha, color.alphaComponent) } - // MARK: - DATE + FORMATTED - - func testRelativeDateStringMinutes() throws { - let date = Date.now.addingTimeInterval(-61) - let string = date.relativeStringToNow(locale: Locale(identifier: "en_US")) - - XCTAssertEqual("1 min. ago", string) - } - - func testRelativeDateStringHours() throws { - let date = Date.now.addingTimeInterval(-3_601) - let string = date.relativeStringToNow(locale: Locale(identifier: "en_US")) - - XCTAssertEqual("1 hr. ago", string) - } - - func testRelativeDateStringDays() throws { - let date = Date.now.addingTimeInterval(-86_400) - let string = date.relativeStringToNow(locale: Locale(identifier: "en_US")) - - XCTAssertEqual("yesterday", string) - } - - // MARK: - STRING + MD5 - - func testMD5GenerationCaseSensitive() throws { - let testString = "CodeEdit" - let md5 = testString.md5(caseSensitive: true) - - let result = "8ba8c8fd0442f7bae4d441e2a3fda706" - XCTAssertEqual(result, md5) - } - - func testMD5Generation() throws { - let testString = "CodeEdit" - let md5 = testString.md5(caseSensitive: false) - - let result = "4cdf122ff382a2d929eddc1a63473ec1" - XCTAssertEqual(result, md5) - } - - // MARK: - STRING + SHA256 - - func testSHA256GenerationCaseSensitive() throws { - let testString = "CodeEdit" - let md5 = testString.sha256(caseSensitive: true) - - let result = "52125689c088f1783e53c48db78a4fe7b3fa10b12d8fba205fcf054e5ef3789a" - XCTAssertEqual(result, md5) - } - - func test256Generation() throws { - let testString = "CodeEdit" - let md5 = testString.sha256(caseSensitive: false) - - let result = "7c3f327eab3860fc823a99623b348afbf1d7aebaec5d21289fbaeab0f6340e4a" - XCTAssertEqual(result, md5) - } - - // MARK: - STRING + REMOVING OCCURRENCES - - func testRemovingNewLines() throws { - let string = "Hello, \nWorld!" - let withoutNewLines = string.removingNewLines() - - let result = "Hello, World!" - XCTAssertEqual(result, withoutNewLines) - } - - func testRemovingSpaces() throws { - let string = "Hello, World!" - let withoutSpaces = string.removingSpaces() - - let result = "Hello,World!" - XCTAssertEqual(result, withoutSpaces) - } - // MARK: - STRING + VALID FILE NAME func testValidFileName() { diff --git a/CodeEditTests/WorkspaceWindow/PanelContributionsTests.swift b/CodeEditTests/WorkspaceWindow/PanelContributionsTests.swift new file mode 100644 index 0000000000..30d96cf794 --- /dev/null +++ b/CodeEditTests/WorkspaceWindow/PanelContributionsTests.swift @@ -0,0 +1,143 @@ +// +// PanelContributionsTests.swift +// CodeEditTests +// +// Created by Matthijs Eikelenboom on 12/08/26. +// + +import Testing +import CodeEditCore +import CodeEditUI +@testable import CodeEdit + +/// Parity tests for the three panels' first-party tabs. +/// +/// Every expectation is a literal, never derived from `PanelTabID` or from the contribution types +/// themselves — a test that computes its expectation the way the code does would pass through any +/// rename. These titles and SF Symbols are the ones the deleted `NavigatorTab`, `InspectorTab` and +/// `UtilityAreaTab` enums shipped; changing one is a silent visual regression, so it must fail here. +/// +/// Only the first-party prefix is asserted: a developer machine may have real extensions installed, +/// which legitimately append tabs. +@MainActor +struct PanelContributionsTests { + + // MARK: - Navigator + + @Test + func navigatorTabsKeepTheirIdsAndOrder() { + let items = navigatorContributions( + extensionManager: ExtensionManager(), + navigator: NoOpWorkspaceNavigator() + ) + #expect(items.prefix(3).map(\.id) == ["project", "sourceControl", "search"]) + } + + @Test + func navigatorTabsKeepTheirTitles() { + let items = navigatorContributions( + extensionManager: ExtensionManager(), + navigator: NoOpWorkspaceNavigator() + ) + #expect(items.prefix(3).map(\.title) == ["Project", "Source Control", "Search"]) + } + + @Test + func navigatorTabsKeepTheirSymbols() { + let items = navigatorContributions( + extensionManager: ExtensionManager(), + navigator: NoOpWorkspaceNavigator() + ) + #expect(items.prefix(3).map(\.systemImage) == ["folder", "vault", "magnifyingglass"]) + } + + // MARK: - Inspector + + @Test + func inspectorTabsKeepTheirIdsAndOrder() { + let items = inspectorContributions( + extensionManager: ExtensionManager(), + showInternalDevelopment: true, + activeEditorState: NoOpActiveEditorState() + ) + #expect(items.prefix(3).map(\.id) == ["file", "gitHistory", "internalDevelopment"]) + } + + @Test + func inspectorTabsKeepTheirTitles() { + let items = inspectorContributions( + extensionManager: ExtensionManager(), + showInternalDevelopment: true, + activeEditorState: NoOpActiveEditorState() + ) + #expect(items.prefix(3).map(\.title) == ["File Inspector", "History Inspector", "Internal Development"]) + } + + @Test + func inspectorTabsKeepTheirSymbols() { + let items = inspectorContributions( + extensionManager: ExtensionManager(), + showInternalDevelopment: true, + activeEditorState: NoOpActiveEditorState() + ) + #expect(items.prefix(3).map(\.systemImage) == ["doc", "clock", "hammer"]) + } + + /// The inspector's developer tab is the only conditional contribution. + @Test + func inspectorIncludesTheDeveloperTabOnlyWhenEnabled() { + let disabled = inspectorContributions( + extensionManager: ExtensionManager(), + showInternalDevelopment: false, + activeEditorState: NoOpActiveEditorState() + ) + let enabled = inspectorContributions( + extensionManager: ExtensionManager(), + showInternalDevelopment: true, + activeEditorState: NoOpActiveEditorState() + ) + + #expect(!disabled.map(\.id).contains("internalDevelopment")) + #expect(enabled.map(\.id).contains("internalDevelopment")) + #expect(enabled.count == disabled.count + 1) + } + + // MARK: - Utility area + + /// `ResolvedSidebar.Kind` has no utility-area case, so this list is always exactly three tabs. + @Test + func utilityAreaTabsKeepTheirIdsAndOrder() { + let items = utilityAreaContributions(extensionManager: ExtensionManager()) + #expect(items.map(\.id) == ["terminal", "debugConsole", "output"]) + } + + @Test + func utilityAreaTabsKeepTheirTitles() { + let items = utilityAreaContributions(extensionManager: ExtensionManager()) + #expect(items.map(\.title) == ["Terminal", "Debug Console", "Output"]) + } + + @Test + func utilityAreaTabsKeepTheirSymbols() { + let items = utilityAreaContributions(extensionManager: ExtensionManager()) + #expect(items.map(\.systemImage) == ["terminal", "ladybug", "list.bullet.indent"]) + } + + // MARK: - Selection call sites + + /// The four `selectedTabID` call sites assign these constants; if one drifts from the + /// contribution that owns it, selection silently stops resolving. + @Test + func selectionConstantsMatchTheContributionsTheyName() { + #expect(PanelTabID.search == "search") + #expect(PanelTabID.debugConsole == "debugConsole") + #expect( + navigatorContributions( + extensionManager: ExtensionManager(), + navigator: NoOpWorkspaceNavigator() + ).contains { $0.id == PanelTabID.search } + ) + #expect(utilityAreaContributions(extensionManager: ExtensionManager()) + .contains { $0.id == PanelTabID.debugConsole }) + } +} diff --git a/CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift b/CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift deleted file mode 100644 index 831ccc8b9b..0000000000 --- a/CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -/// Tracks scroll offset in scrollable views -public struct ViewOffsetPreferenceKey: PreferenceKey { - public typealias Value = CGFloat - public static var defaultValue = CGFloat.zero - public static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} diff --git a/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift b/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift index dbb5655dcf..fdf7f1ea53 100644 --- a/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift +++ b/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift @@ -1,5 +1,5 @@ // -// ActivityViewerTasksMenuTests.swift +// TasksMenuUITests.swift // CodeEditUITests // // Created by Khan Winter on 1/3/25. diff --git a/CodeEditUITests/Other Tests/HideInterfaceTests.swift b/CodeEditUITests/Other Tests/HideInterfaceTests.swift index bf804747ca..f8adeca1e5 100644 --- a/CodeEditUITests/Other Tests/HideInterfaceTests.swift +++ b/CodeEditUITests/Other Tests/HideInterfaceTests.swift @@ -1,5 +1,5 @@ // -// HiderInterfaceTests.swift +// HideInterfaceTests.swift // CodeEditUITests // // Created by Simon Kudsk on 14/05/2025. diff --git a/Documentation.docc/About/About Window.md b/Documentation.docc/About/About Window.md deleted file mode 100644 index bf811941cf..0000000000 --- a/Documentation.docc/About/About Window.md +++ /dev/null @@ -1,22 +0,0 @@ -# About Window - -The About Window displays general information about the app and acknowledgements to external dependencies. - -## Topics - -### About View - -- ``AboutView`` -- ``AboutWindow`` - -### Acknowledgements - -- ``AcknowledgementsView`` -- ``AcknowledgementsViewModel`` -- ``AcknowledgementsViewWindowController`` -- ``AcknowledgementRowView`` - -- ``AcknowledgementObject`` -- ``AcknowledgementDependency`` -- ``AcknowledgementPackageState`` -- ``AcknowledgementPin`` diff --git a/Documentation.docc/App Window/Adding New Tab Type.md b/Documentation.docc/App Window/Adding New Tab Type.md deleted file mode 100644 index 6541ddc178..0000000000 --- a/Documentation.docc/App Window/Adding New Tab Type.md +++ /dev/null @@ -1,94 +0,0 @@ -# Adding a New Tab Type - -This article is about how to add a new tab type to `TabBar` - -## Overview - -First of all, each data type to be represented as tab in the UI should conform to -``EditorTabRepresentable`` protocol. For example, this is how it is done for -`FileItem`: - -```swift -final class FileItem: Identifiable, Codable, EditorTabRepresentable { - public var tabID: EditorTabID { - .codeEditor(id) - } - - public var title: String { - self.url.lastPathComponent - } - - public var icon: Image { - Image(systemName: self.systemImage) - } - - public var iconColor: Color { - ... - } - - ... -} -``` - -### Add new item identifier case - -Each new tab type must have new identifier case, for example: -```swift -public enum EditorTabID: Codable, Identifiable, Hashable { - public var id: String { - switch self { - ... - case .gitHistory(let repo): - return "gitHistory_\(repo)" - } - } - - /// Represents Git history - case gitHistory(String) -} -``` - -### Opening and closing new tab types - -Tabs are opened using ``WorkspaceDocument/openTab(item:)`` method. It does a set of common -things for all tabs. But also it calls a private method based on the ``EditorTabID`` of the -item. The private method for your ``EditorTabRepresentable`` MUST persist this item -somewhere (I recommend persisting them in ``WorkspaceSelectionState``). - -The same is for closing tabs using ``WorkspaceDocument/closeTab(item:)`` method. -Closing multiple tabs at once is handled by common functions, so there are no changes -required for them. - -``WorkspaceDocument/close()`` calls `WorkspaceDocument.saveSelectionState()` to persist Workspace Selection State to UserDefaults. - -``WorkspaceDocument/read(from:ofType:)`` calls `WorkspaceDocument.readSelectionState()` to retrieve Workspace Selection State from UserDefaults. - -Also, because previously opened tabs are persisted in UserDefaults, -they should be recovered some way later. To recover new tab types you need to add -a case for ``WorkspaceDocument/read(from:ofType:)`` to let it know how to recover your new tab type. - -If you need to persist something as code editor tabs do for files, then you need to add -functionality to persist changes to ``WorkspaceDocument/close()``. - -Also, you need to add a case for new tab type to -``WorkspaceSelectionState/getItemByTab(id:)``. It will allow to use -``WorkspaceSelectionState/selected`` property and other features. - -### Adding a view for the new tab type - -To add a view for new tab type, you need to add a case for your tab type to -``WorkspaceView/tabContent``: - -```swift -@ViewBuilder var tabContent: some View { - if let tabID = workspace.selectionState.selectedId { - switch tabID { - ... - case .gitHistory: - GitHistoryView(windowController: windowController, workspace: workspace) - } - } else { - noEditor - } -} -``` diff --git a/Documentation.docc/App Window/App Window.md b/Documentation.docc/App Window/App Window.md deleted file mode 100644 index d55cd15512..0000000000 --- a/Documentation.docc/App Window/App Window.md +++ /dev/null @@ -1,48 +0,0 @@ -# App Window - -A collection of all the views that make up the main app window. - -## Topics - -### Window Controller - -- ``CodeEditWindowController`` -- ``WorkspaceView`` - -### Main Content - -- ``EditorAreaView`` -- ``EditorAreaFileView`` -- ``CodeFileView`` -- ``NonTextFileView`` -- ``AnyFileView`` -- ``LoadingFileView`` -- ``ImageFileView`` -- ``PDFFileView`` - -### JumpBar - -- ``JumpBarView`` -- ``JumpBarComponent`` -- ``JumpBarMenu`` -- ``JumpBarMenuItem`` - -### Navigator Sidebar - -- ``NavigatorSidebarView`` - -### Inspector Sidebar - -- ``InspectorAreaView`` - -### Status Bar - -- ``StatusBarView`` - -### Tab Bar - -- ``TabBarView`` - -### Terminal Emulator - -- ``TerminalEmulatorView`` diff --git a/Documentation.docc/App Window/InspectorSidebarView.md b/Documentation.docc/App Window/InspectorSidebarView.md deleted file mode 100644 index 6665c26317..0000000000 --- a/Documentation.docc/App Window/InspectorSidebarView.md +++ /dev/null @@ -1,37 +0,0 @@ -# ``CodeEdit/InspectorAreaView`` - -## Topics - -### Toolbars - -- ``InspectorAreaToolbarTop`` - -### File Inspector - -- ``FileInspectorView`` -- ``FileInspectorModel`` -- ``FileLocation`` -- ``IndentUsing`` -- ``LanguageType`` -- ``LineEndings`` -- ``TextEncoding`` - -### History Inspector - -- ``HistoryInspectorView`` -- ``HistoryInspectorModel`` -- ``HistoryInspectorItemView`` -- ``HistoryInspectorNoHistoryView`` -- ``HistoryPopoverView`` - -### Quick Help Inspector - -- ``QuickHelpInspectorView`` - -### No Selection - -- ``NoSelectionInspectorView`` - -### File List - -- ``FileTypeList`` diff --git a/Documentation.docc/App Window/NavigatorSidebarView.md b/Documentation.docc/App Window/NavigatorSidebarView.md deleted file mode 100644 index 3ead03b1c6..0000000000 --- a/Documentation.docc/App Window/NavigatorSidebarView.md +++ /dev/null @@ -1,44 +0,0 @@ -# ``CodeEdit/NavigatorSidebarView`` - -## Topics - -### Toolbars - -- ``NavigatorSidebarToolbarTop`` -- ``NavigatorSidebarToolbarBottom`` - -### Project Navigator - -- ``ProjectNavigatorView`` -- ``OutlineView`` -- ``OutlineViewController`` -- ``OutlineMenu`` -- ``OutlineTableViewCell`` -- ``OutlineTableViewCellDelegate`` - -### Source Control Navigator - -- ``SourceControlNavigatorView`` -- ``SourceControlModel`` -- ``SourceControlSearchToolbar`` -- ``SourceControlToolbarBottom`` -- ``SourceControlNavigatorRepositoriesView`` -- ``SourceControlNavigatorChangesView`` -- ``SourceControlNavigatorChangedFileView`` - -### Find Navigator - -- ``FindNavigatorView`` -- ``FindNavigatorSearchBar`` -- ``FindNavigatorModeSelector`` -- ``FindNavigatorResultList`` -- ``FindNavigatorListViewController`` -- ``FindNavigatorListMatchCell`` - -### Extension Navigator - -- ``ExtensionNavigatorView`` -- ``ExtensionNavigatorItemView`` -- ``ExtensionNavigatorData`` -- ``ExtensionInstallationView`` -- ``ExtensionInstallationViewModel`` diff --git a/Documentation.docc/App Window/StatusBarView.md b/Documentation.docc/App Window/StatusBarView.md deleted file mode 100644 index c786a4afad..0000000000 --- a/Documentation.docc/App Window/StatusBarView.md +++ /dev/null @@ -1,25 +0,0 @@ -# ``CodeEdit/StatusBarView`` - -## Topics - -### Model - -- ``ImageDimensions`` - -### View Model - -- ``StatusBarViewModel`` - -### View Modifiers - -- ``UpdateStatusBarInfo`` - -### Items - -- ``StatusBarMenuStyle`` -- ``StatusBarBreakpointButton`` -- ``StatusBarIndentSelector`` -- ``StatusBarEncodingSelector`` -- ``StatusBarLineEndSelector`` -- ``StatusBarToggleUtilityAreaButton`` -- ``StatusBarCursorPositionLabel`` diff --git a/Documentation.docc/App Window/TabBarView.md b/Documentation.docc/App Window/TabBarView.md deleted file mode 100644 index 99288ffa7a..0000000000 --- a/Documentation.docc/App Window/TabBarView.md +++ /dev/null @@ -1,23 +0,0 @@ -# ``CodeEdit/TabBarView`` - -## Topics - -### Articles - -- - -### Model - -- ``EditorTabID`` -- ``EditorTabRepresentable`` - -### Components - -- ``EditorTabView`` -- ``TabBarContextMenu`` -- ``EditorTabButtonStyle`` -- ``TabDivider`` -- ``TabBarTopDivider`` -- ``TabBarBottomDivider`` -- ``TabBarAccessoryIcon`` -- ``TabBarXcodeBackground`` diff --git a/Documentation.docc/App Window/UtilityAreaView.md b/Documentation.docc/App Window/UtilityAreaView.md deleted file mode 100644 index 50e5e93988..0000000000 --- a/Documentation.docc/App Window/UtilityAreaView.md +++ /dev/null @@ -1,27 +0,0 @@ -# ``CodeEdit/UtilityAreaView`` - -## Topics - -### Model - -- ``UtilityAreaTab`` - -### View Model - -- ``UtilityAreaViewModel`` -- ``UtilityAreaTabViewModel`` - -### Utility - -- ``UtilityAreaTerminal`` -- ``UtilityAreaTerminalTab`` -- ``UtilityAreaDebugView`` -- ``UtilityAreaOutputView`` - -### Toolbar - -- ``UtilityAreaView`` -- ``UtilityAreaSplitTerminalButton`` -- ``UtilityAreaMaximizeButton`` -- ``UtilityAreaClearButton`` -- ``UtilityAreaFilterTextField`` diff --git a/Documentation.docc/AppPreferences/AppPreferences.md b/Documentation.docc/AppPreferences/AppPreferences.md deleted file mode 100644 index 93875f3ecd..0000000000 --- a/Documentation.docc/AppPreferences/AppPreferences.md +++ /dev/null @@ -1,37 +0,0 @@ -# ``CodeEdit/Settings`` - -## Topics - -### Getting Started - -- -- - -### Settings Model - -- ``SettingsModel`` -- ``SoftwareUpdater`` - -### Settings Section Views - -- ``GeneralSettingsView`` -- ``ThemeSettingsView`` -- ``TextEditingSettingsView`` -- ``TerminalSettingsView`` -- ``LocationsSettingsView`` -- ``KeybindingsSettingsView`` -- ``AccountSettingsView`` -- ``SourceControlSettingsView`` -- ``SettingsPlaceholderView`` - -### Helper Views - -- ``SettingsContent`` -- ``SettingsSection`` -- ``SettingsColorPicker`` -- ``SettingsToolbar`` - -### Theme Settings Model - -- ``Theme`` -- ``ThemeModel`` diff --git a/Documentation.docc/AppPreferences/Create a View.md b/Documentation.docc/AppPreferences/Create a View.md deleted file mode 100644 index 99f09dd207..0000000000 --- a/Documentation.docc/AppPreferences/Create a View.md +++ /dev/null @@ -1,169 +0,0 @@ -# Create a View - -Now that you followed the guide it's time to create a view. - -## Add setting to existing Section - -In our example we added `ourNewOption` in ``Settings/GeneralSettings``. - -Now let's take a look at the ``GeneralSettingsView``. - -```swift -import SwiftUI - -struct GeneralSettingsView: View { - @AppSettings(\.general) - var settings - - var body: some View { - SettingsForm { - Section { - appearance - fileIconStyle - navigatorTabBarPosition - inspectorTabBarPosition - ... - } - } - } -} -``` - -As you can see ``SettingsModel`` is already setup and ready to use. - -To add your option toggle below the other options just add something like this: - -```swift -private extension GeneralSettingsView { - // MARK: - Settings View - - private var yourOption: some View { - Toggle("Your text", isOn: $general.yourNewOption) - } -} -``` - -Then add it to `var body: some View` - -```swift -struct GeneralSettingsView: View { - var body: some View { - SettingsForm { - Section { - appearanceSection - showIssuesSection - fileExtensionsSection - // REMOVEME: et cetera - yourOptionSection - } - } - } -} -``` - -And now you're done! - -## Implement a new section - -> Tip: Rename YourSection to the section name that you want - -To implement a new section first create a new folder inside the `Pages` folder and name it accordingly. - -Inside the folder create a new SwiftUI view and name it "YourSectionSettingsView.swift". - -Then create a new folder inside called `Models` and inside of it create a file named "YourSectionSettings.swift" - - -> Tip: The order that pages are arranged in the array is the same as in the settings window, the first array member will be the top item -``` - -Then find the file `SettingsPage.swift` and add `YourSection` to the `enum Name` like this: - -```swift -enum Name: String { - case general = "General" - case advanced = "Advanced" - // et cetera - case yourSection = "YourSection" -} -``` - -Back in `YourSectionView.swift` implement your option like this: - -```swift -import SwiftUI - -struct YourSectionSettingsView: View { - @AppSettings(\.yourSection) - var yourSection - - var body: some View { - SettingsForm { - Section { - yourToggleSection - } - } - } -} - -private extension YourSectionSettingsView { - // MARK: - Settings Views - - private var yourToggle: some View { - Toggle("Your option", isOn: $yourSection.yourNewOption) - } -} -``` - -There are 3 more steps, almost done. - -Open `ModelNameToSettingName.swift` and add your translated search result: - -```swift -let translator: [String: String] = [ - // MARK: - General Settings - "appAppearance": NSLocalizedString("Appearance", comment: ""), - "fileIconStyle": NSLocalizedString("File Icon Style", comment: ""), - // etc - // MARK: - Your Section - "yourOption": NSLocalizedString("Your Option", comment: "Your translation comment") -] -``` - -Now, open `SettingsView.swift` and add your section to the `populatePages()` method: - -```swift -/// Creates all the neccessary pages -private func populatePages() -> [SettingsPage] { - var pages = [SettingsPage]() - let settingsData = SettingsData() - - let generalSettings = SettingsPage(.general, baseColor: .gray, icon: .system("gear")) - pages = createPageAndSettings(settingsData.general, parent: generalSettings, prePages: pages) - - let accountsSettings = SettingsPage(.accounts, baseColor: .blue, icon: .system("at")) - pages = createPageAndSettings(settingsData.accounts, parent: accountsSettings, prePages: pages) - - // etc - let yourSectionSettings = SettingsPage(.yourSection, baseColor: /* add color here */, icon: /* add icon */) - pages = createPageAndSettings(settingsData.yourSection, parent: yourSectionSettings, prePages: pages) - - return pages -} -``` - - -When you are done, add `YourSectionSettingsView` to `SettingsView.swift`: - -```swift -Group { - switch selectedPage { - case .general: - GeneralSettingsView().environmentObject(updater) - case .yourSection: - YourSectionSettingsView() - default: - Text("Implementation Needed").frame(alignment: .center) - } -} -``` diff --git a/Documentation.docc/AppPreferences/Getting Started.md b/Documentation.docc/AppPreferences/Getting Started.md deleted file mode 100644 index c6f4f02271..0000000000 --- a/Documentation.docc/AppPreferences/Getting Started.md +++ /dev/null @@ -1,98 +0,0 @@ -# Getting Started - -There are a few things to consider when using the ``Settings``. - -## Reading/Writing Values - -The Settings can be accessed from everywhere in the app like this: - -```swift -@AppSettings(\.settingName) -var setting -``` - -```swift -Toggle("Enable some Feature", value: $setting) -``` - -## Creating a New Preference - -When implementing a new feature, we might have some options in regards to this new feature we want to show the user in the apps Settings Window. - -### Find a Section - -The settings window is structured in different sections. Figure out in which section your new option should appear in. - -If the section is already populated with other options (e.g. ``Settings/GeneralSettings``), just add your new option like this: - -```swift -struct GeneralSettings: Codable, Hashable { - - // ... - - // This will be your new option. Be sure to provide a default value - public var yourNewOption: Bool = true - - public init() {} - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - // ... - - // Try to decode the value from json. - self.yourNewOption = try container.decodeIfPresent( - Bool.self, - forKey: .yourNewOption - ) ?? true // If the key is not present in the json, set the default value - } -} -``` - -### Create a Section - -In some cases in early development the section you decided on where to put your option in might not yet have been implemented. In this -case you can create a new `struct` inside ``Settings`` like this: - -```swift -public extension YourNewSection: Codable { - - // This will be your new option. Be sure to provide a default value - public var yourNewOption: Bool = true - - public init() {} - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Try to decode the value from json. - self.yourNewOption = try container.decodeIfPresent( - Bool.self, - forKey: .yourNewOption - ) ?? true // If the key is not present in the json, set the default value - } -} -``` - -Now let's add the new section to ``Settings`` like this: - -```swift -public struct Settings: Codable { - // ... - - // Add your new section above the `public init() {}` - public var yourNewSection: YourNewSection = .init() - - // ... -} -``` - -## Topics - -### Up Next - -- - -### Main Components - -- ``Settings`` -- ``SettingsModel`` diff --git a/Documentation.docc/AppPreferences/Sections/AccountPreferencesView.md b/Documentation.docc/AppPreferences/Sections/AccountPreferencesView.md deleted file mode 100644 index adf62c7a6c..0000000000 --- a/Documentation.docc/AppPreferences/Sections/AccountPreferencesView.md +++ /dev/null @@ -1,23 +0,0 @@ -# ``CodeEdit/AccountSettingsView`` - -## Topics - -### Model - -- ``Settings/AccountsSettings`` -- ``SourceControlAccounts`` -- ``SourceControlProvider`` - -### Views - -- ``AccountListItemView`` -- ``AccountSelectionDialog`` -- ``GitAccountItem`` -- ``GitAccountItemView`` - -### Login Views - -- ``GitLabHostedLoginView`` -- ``GitLabLoginView`` -- ``GitHubLoginView`` -- ``GitHubEnterpriseLoginView`` diff --git a/Documentation.docc/AppPreferences/Sections/GeneralPreferencesView.md b/Documentation.docc/AppPreferences/Sections/GeneralPreferencesView.md deleted file mode 100644 index a59d4502b6..0000000000 --- a/Documentation.docc/AppPreferences/Sections/GeneralPreferencesView.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``CodeEdit/GeneralSettingsView`` - -## Topics - -### Model - -- ``Settings/GeneralSettings`` diff --git a/Documentation.docc/AppPreferences/Sections/KeybindingsPreferencesView.md b/Documentation.docc/AppPreferences/Sections/KeybindingsPreferencesView.md deleted file mode 100644 index 376c8416a8..0000000000 --- a/Documentation.docc/AppPreferences/Sections/KeybindingsPreferencesView.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``CodeEdit/KeybindingsSettingsView`` - -## Topics - -### Model - -- ``Settings/KeybindingsSettings`` diff --git a/Documentation.docc/AppPreferences/Sections/SourceControlPreferencesView.md b/Documentation.docc/AppPreferences/Sections/SourceControlPreferencesView.md deleted file mode 100644 index 2ab028ebed..0000000000 --- a/Documentation.docc/AppPreferences/Sections/SourceControlPreferencesView.md +++ /dev/null @@ -1,14 +0,0 @@ -# ``CodeEdit/SourceControlSettingsView`` - -## Topics - -### Model - -- ``Settings/SourceControlSettings`` -- ``IgnoredFiles`` - -### Views - -- ``SourceControlGeneralView`` -- ``SourceControlGitView`` -- ``IgnoredFileView`` diff --git a/Documentation.docc/AppPreferences/Sections/TerminalPreferencesView.md b/Documentation.docc/AppPreferences/Sections/TerminalPreferencesView.md deleted file mode 100644 index 63e727bea8..0000000000 --- a/Documentation.docc/AppPreferences/Sections/TerminalPreferencesView.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``CodeEdit/TerminalSettingsView`` - -## Topics - -### Model - -- ``Settings/TerminalSettings`` diff --git a/Documentation.docc/AppPreferences/Sections/TextEditingPreferencesView.md b/Documentation.docc/AppPreferences/Sections/TextEditingPreferencesView.md deleted file mode 100644 index 3af61fbc42..0000000000 --- a/Documentation.docc/AppPreferences/Sections/TextEditingPreferencesView.md +++ /dev/null @@ -1,7 +0,0 @@ -# ``CodeEdit/TextEditingSettingsView`` - -## Topics - -### Model - -- ``Settings/TextEditingSettings`` diff --git a/Documentation.docc/AppPreferences/Sections/ThemePreferencesView.md b/Documentation.docc/AppPreferences/Sections/ThemePreferencesView.md deleted file mode 100644 index 928164a8b7..0000000000 --- a/Documentation.docc/AppPreferences/Sections/ThemePreferencesView.md +++ /dev/null @@ -1,15 +0,0 @@ -# ``CodeEdit/ThemeSettingsView`` - -## Topics - -### Model - -- ``Settings/ThemeSettings`` -- ``ThemeModel`` - -### Views - -- ``PreviewThemeView`` -- ``TerminalThemeView`` -- ``EditorThemeView`` -- ``ThemePreviewIcon`` diff --git a/Documentation.docc/AppPreferences/Themes.md b/Documentation.docc/AppPreferences/Themes.md deleted file mode 100644 index deff4204bf..0000000000 --- a/Documentation.docc/AppPreferences/Themes.md +++ /dev/null @@ -1,191 +0,0 @@ -# ``CodeEdit/Theme`` - -## Overview - -A ``Theme`` is stored in a `theme_name.json` file in the `~/Library/Application Support/CodeEdit/themes/` directory. There are a -couple of bundled themes that will automatically be put there once the app starts. - -Once a `JSON` file is loaded, the ``Theme`` gets added to ``ThemeModel/themes``. - -They can either be ``Theme/ThemeType/dark`` or ``Theme/ThemeType/light``. - -## JSON Structure - -```json -{ - "author" : "CodeEdit", - "name" : "codeedit-xcode-dark", - "displayName" : "Xcode Dark", - "description" : "Xcode dark theme.", - "version" : "0.0.1", - "license" : "MIT", - "type" : "dark", - "distributionURL" : "https:\/\/github.com\/CodeEditApp\/CodeEdit", - "editor" : { ... }, - "terminal" : { ... } -} -``` - -| Key | Description | -| --- | ----------- | -| ``author`` | Your Name | -| ``name`` | A unique string representing the theme. _It's good practice to start it with your name or domain to make sure it is unique._ | -| ``displayName`` | The name that will appear in the UI | -| description / ``metadataDescription`` | A short description that will appear when hovering over the theme thumbnail | -| ``version`` | A version number | -| ``license`` | Which license your theme is published under | -| type / ``appearance`` | The type of the theme [**dark**, **light**] | -| ``distributionURL`` | A URL to your web presentation | -| ``editor`` | A collection of colors for the editor | -| ``terminal`` | A collection of colors for the terminal | - -### Editor - -```json -{ - "invisibles" : { - "color" : "#424D5B" - }, - "comments" : { - "color" : "#73A74E" - }, - "numbers" : { - "color" : "#D0BF69" - }, - "commands" : { - "color" : "#67B7A4" - }, - "lineHighlight" : { - "color" : "#23252B" - }, - "values" : { - "color" : "#A167E6" - }, - "background" : { - "color" : "#1F1F24" - }, - "keywords" : { - "color" : "#FF7AB3" - }, - "text" : { - "color" : "#D9D9D9" - }, - "insertionPoint" : { - "color" : "#D9D9D9" - }, - "strings" : { - "color" : "#FC6A5D" - }, - "selection" : { - "color" : "#515B70" - }, - "types" : { - "color" : "#5DD8FF" - }, - "variables" : { - "color" : "#41A1C0" - }, - "attributes" : { - "color" : "#D0A8FF" - }, - "characters" : { - "color" : "#D0BF69" - } -} -``` - -### Terminal - -```json -{ - "white" : { - "color" : "#d9d9d9" - }, - "brightMagenta" : { - "color" : "#af52de" - }, - "brightRed" : { - "color" : "#ff3b30" - }, - "blue" : { - "color" : "#007aff" - }, - "red" : { - "color" : "#ff3b30" - }, - "green" : { - "color" : "#28cd41" - }, - "boldText" : { - "color" : "#d9d9d9" - }, - "brightGreen" : { - "color" : "#28cd41" - }, - "background" : { - "color" : "#1f2024" - }, - "cursor" : { - "color" : "#d9d9d9" - }, - "selection" : { - "color" : "#515b70" - }, - "magenta" : { - "color" : "#af52de" - }, - "black" : { - "color" : "#1f2024" - }, - "text" : { - "color" : "#d9d9d9" - }, - "brightWhite" : { - "color" : "#ffffff" - }, - "brightBlue" : { - "color" : "#007aff" - }, - "brightYellow" : { - "color" : "#ffff00" - }, - "cyan" : { - "color" : "#59adc4" - }, - "yellow" : { - "color" : "#ffcc00" - }, - "brightCyan" : { - "color" : "#55bef0" - }, - "brightBlack" : { - "color" : "#8e8e93" - } -} -``` - -## Topics - -### General Info - -- ``author`` -- ``name`` -- ``displayName`` -- ``metadataDescription`` -- ``version`` -- ``license`` -- ``appearance`` -- ``distributionURL`` -- ``ThemeType`` - -### Editor - -- ``Theme/EditorColors`` -- ``editor`` -- ``Attributes`` - -### Terminal - -- ``Theme/TerminalColors`` -- ``terminal`` -- ``Attributes`` diff --git a/Documentation.docc/CodeEditUI/CodeEditUI.md b/Documentation.docc/CodeEditUI/CodeEditUI.md deleted file mode 100644 index 71f8b89de6..0000000000 --- a/Documentation.docc/CodeEditUI/CodeEditUI.md +++ /dev/null @@ -1,23 +0,0 @@ -# CodeEditUI - -A collection of reusable UI elements for `CodeEdit`. - -## Overview - -This module contains UI elements that can be reused throughout the `CodeEdit` app. - -## Topics - -### Controls - -- ``ToolbarBranchPicker`` -- ``HelpButton`` -- ``SegmentedControl`` -- ``SettingsTextEditor`` - -### Other - -- ``EffectView`` -- ``OverlayPanel`` -- ``PressActions`` -- ``PanelDivider`` diff --git a/Documentation.docc/CodeEditUI/HelpButton.md b/Documentation.docc/CodeEditUI/HelpButton.md deleted file mode 100644 index 062d5db448..0000000000 --- a/Documentation.docc/CodeEditUI/HelpButton.md +++ /dev/null @@ -1,13 +0,0 @@ -# ``CodeEdit/HelpButton`` - -## Usage - -```swift -HelpButton { - // an action to perform on click -} -``` - -## Preview - -![Help Button](HelpButton_View.png) diff --git a/Documentation.docc/CodeEditUI/Resources/BranchPicker_View.png b/Documentation.docc/CodeEditUI/Resources/BranchPicker_View.png deleted file mode 100644 index cd5d891138..0000000000 Binary files a/Documentation.docc/CodeEditUI/Resources/BranchPicker_View.png and /dev/null differ diff --git a/Documentation.docc/CodeEditUI/Resources/FontPicker_View.png b/Documentation.docc/CodeEditUI/Resources/FontPicker_View.png deleted file mode 100644 index b221f65ac1..0000000000 Binary files a/Documentation.docc/CodeEditUI/Resources/FontPicker_View.png and /dev/null differ diff --git a/Documentation.docc/CodeEditUI/Resources/HelpButton_View.png b/Documentation.docc/CodeEditUI/Resources/HelpButton_View.png deleted file mode 100644 index 4e94d2b208..0000000000 Binary files a/Documentation.docc/CodeEditUI/Resources/HelpButton_View.png and /dev/null differ diff --git a/Documentation.docc/CodeEditUI/Resources/SegmentedControl_View.png b/Documentation.docc/CodeEditUI/Resources/SegmentedControl_View.png deleted file mode 100644 index 506e1e0ea3..0000000000 Binary files a/Documentation.docc/CodeEditUI/Resources/SegmentedControl_View.png and /dev/null differ diff --git a/Documentation.docc/CodeEditUI/SegmentedControl.md b/Documentation.docc/CodeEditUI/SegmentedControl.md deleted file mode 100644 index 16276e63bb..0000000000 --- a/Documentation.docc/CodeEditUI/SegmentedControl.md +++ /dev/null @@ -1,20 +0,0 @@ -# ``CodeEdit/SegmentedControl`` - -## Usage - -```swift -@State var selected: Int = 0 -var items: [String] = ["Tab 1", "Tab 2"] - -SegmentedControl($selected, options: items) -``` - -## Preview - -![Segmented Control](SegmentedControl_View.png) - -## Topics - -### Item - -- ``SegmentedControlItem`` diff --git a/Documentation.docc/CodeEditUI/ToolbarBranchPicker.md b/Documentation.docc/CodeEditUI/ToolbarBranchPicker.md deleted file mode 100644 index c4cfb6663e..0000000000 --- a/Documentation.docc/CodeEditUI/ToolbarBranchPicker.md +++ /dev/null @@ -1,39 +0,0 @@ -# ``CodeEdit/ToolbarBranchPicker`` - -## Overview - -When the current project is a git repository, this will show the currently -checked-out branch as a subtitle. Once a tap is registered, a popup will -appear displaying the currently checked-out branch and all other local branches. - -This view should be set to the `view` property in a [`NSToolbarItem`](https://developer.apple.com/documentation/appkit/nstoolbaritem). - -## Usage - -First make sure a `WorkspaceDocument` is accessible in the context. - -```swift -var workspace: WorkspaceDocument? -``` - -Then in -[`toolbar(_:itemForItemIdentifier:willBeInsertedIntoToolbar:)`](https://developer.apple.com/documentation/appkit/nstoolbardelegate/1516985-toolbar), -create a new [`NSToolbarItem`](https://developer.apple.com/documentation/appkit/nstoolbaritem): - -```swift -let toolbarItem = NSToolbarItem(itemIdentifier: /* Identifier */) - -// create a NSHostingView -let view = BranchPickerToolbarItem(workspace?.workspaceClient) -let hostingView = NSHostingView(rootView: view) - -// set the view property of the toolbar item -toolbarItem.view = hostingView - -// return the toolbar item -return toolbarItem -``` - -## Preview - -![BranchPicker](BranchPicker_View.png) diff --git a/Documentation.docc/Documentation.md b/Documentation.docc/Documentation.md deleted file mode 100644 index bdc91525fc..0000000000 --- a/Documentation.docc/Documentation.md +++ /dev/null @@ -1,92 +0,0 @@ -# ``CodeEdit`` - -## Topics - -### About Window - -- - -### Settings - -- ``Settings`` - -### App Window - -- - -### CodeEditExtension - -- ``ExtensionManager`` -- ``FolderMonitor`` - -### CodeEditUI - -- - -### CodeFile - -- ``CodeFileDocument`` -- ``CodeFileError`` - -### CommandPalette - -- ``CommandPaletteView`` -- ``CommandPaletteViewModel`` - -### Documents - -- -- ``WorkspaceDocument`` -- ``CEWorkspaceFile`` -- ``CEWorkspaceFileManager`` -- ``CodeFileDocument`` -- ``CodeEditDocumentController`` - -### Feedback - -- ``FeedbackView`` -- ``FeedbackWindowController`` -- ``FeedbackToolbar`` -- ``FeedbackModel`` -- ``FeedbackType`` -- ``FeedbackIssueArea`` - -### Git - -- - -### Keybindings - -- ``KeybindingManager`` -- ``CommandManager`` -- ``Command`` -- ``CommandClosureWrapper`` -- ``KeyboardShortcutWrapper`` - -### LanguageServerProtocol - -- ``LSPClient`` - -### OpenQuickly - -- ``OpenQuicklyView`` -- ``OpenQuicklyListItemView`` -- ``OpenQuicklyViewModel`` -- ``OpenQuicklyPreviewView`` - -### Search - -- ``SearchModeModel`` -- ``SearchResultModel`` -- ``SearchResultMatchModel`` -- ``SearchResultLabel`` - -### Utils - -- ``CodeEditKeychain`` -- ``ShellClient`` -- ``FileIcon`` - -### Welcome - -- diff --git a/Documentation.docc/FileManagement/FileManagement.md b/Documentation.docc/FileManagement/FileManagement.md deleted file mode 100644 index 11852f09c2..0000000000 --- a/Documentation.docc/FileManagement/FileManagement.md +++ /dev/null @@ -1,10 +0,0 @@ -# File Management - -Working with files and directories in CodeEdit. - -## Overview - -CodeEdit manages files using three classes: -- ``CEWorkspaceFile`` for representing files and other file system objects. -- ``CEWorkspaceFileManager`` for loading, modifying, and listening to the file system. -- ``CodeFileDocument`` for loading contents of files for editing. diff --git a/Documentation.docc/Git/Git.md b/Documentation.docc/Git/Git.md deleted file mode 100644 index 01813c3f41..0000000000 --- a/Documentation.docc/Git/Git.md +++ /dev/null @@ -1,111 +0,0 @@ -# Git - -## Topics - -### Client - -- ``GitClient`` - -### Protocols - -- ``GitRouter`` -- ``GitJSONPostRouter`` -- ``GitRouterConfiguration`` -- ``GitURLSession`` -- ``GitURLSessionDataTaskProtocol`` - -### Structs - -- ``GitCommit`` -- ``GitAccountItem`` -- ``GitChangedFile`` -- ``GitHTTPHeader`` - -### Enums - -- ``GitHTTPEncoding`` -- ``GitHTTPMethod`` -- ``GitType`` -- ``GitTime`` -- ``GitURL`` -- ``GitSortDirection`` -- ``GitSortType`` - -### Views - -- ``GitCheckoutBranchView`` -- ``GitCloneView`` -- ``GitHubEnterpriseLoginView`` -- ``GitHubLoginView`` -- ``GitLabHostedLoginView`` -- ``GitLabLoginView`` - -### GitHub - -- ``GitHubFile`` -- ``GitHubGist`` -- ``GitHubIssue`` -- ``GitHubPullRequest`` -- ``GitHubRepositories`` -- ``GitHubUser`` -- ``GitHubAccount`` -- ``GitHubComment`` -- ``GitHubReview`` -- ``GitHubTokenConfiguration`` -- ``GitHubRouter`` -- ``GitHubUserRouter`` -- ``GitHubGistRouter`` -- ``GitHubIssueRouter`` -- ``GitHubOAuthRouter`` -- ``GitHubOAuthConfiguration`` -- ``GitHubOpenness`` -- ``GitHubPreviewHeader`` -- ``GitHubPublicKeyRouter`` -- ``GitHubPullRequestRouter`` -- ``GitHubRepositoryRouter`` -- ``GitHubReviewsRouter`` - -### GitLab - -- ``GitLabAvatarURL`` -- ``GitLabCommit`` -- ``GitLabCommitComment`` -- ``GitLabCommitDiff`` -- ``GitLabCommitStats`` -- ``GitLabCommitStatus`` -- ``GitLabEvent`` -- ``GitLabEventData`` -- ``GitLabGroupAccess`` -- ``GitLabPermissions`` -- ``GitLabProject`` -- ``GitLabProjectAccess`` -- ``GitLabProjectHook`` -- ``GitLabUser`` -- ``GitLabNamespace`` -- ``GitLabEventNote`` -- ``GitLabAccount`` -- ``GitLabTokenConfiguration`` -- ``GitLabOAuthConfiguration`` -- ``GitLabPrivateTokenConfiguration`` -- ``GitLabSort`` -- ``GitLabOrderBy`` -- ``GitLabVisibility`` -- ``GitLabVisibilityLevel`` -- ``GitLabUserRouter`` -- ``GitLabCommitRouter`` -- ``GitLabProjectRouter`` -- ``GitLabOAuthRouter`` - -### BitBucket - -- ``BitBucketEmail`` -- ``BitBucketRepositories`` -- ``BitBucketUser`` -- ``BitBucketAccount`` -- ``BitBucketOAuthConfiguration`` -- ``BitBucketTokenConfiguration`` -- ``BitBucketOAuthRouter`` -- ``BitBucketRepositoryRouter`` -- ``BitBucketTokenRouter`` -- ``BitBucketUserRouter`` -- ``BitbucketPaginatedResponse`` diff --git a/Documentation.docc/KeyChain/CodeEditKeychain.md b/Documentation.docc/KeyChain/CodeEditKeychain.md deleted file mode 100644 index 4b8cf0c012..0000000000 --- a/Documentation.docc/KeyChain/CodeEditKeychain.md +++ /dev/null @@ -1,12 +0,0 @@ -# ``CodeEdit/CodeEditKeychain`` - -## Topics - -### Articles - -- - -### Enumerations - -- ``CodeEditKeychainAccessOptions`` -- ``CodeEditKeychainConstants`` diff --git a/Documentation.docc/KeyChain/What is Keychain.md b/Documentation.docc/KeyChain/What is Keychain.md deleted file mode 100644 index 7c6b8765da..0000000000 --- a/Documentation.docc/KeyChain/What is Keychain.md +++ /dev/null @@ -1,66 +0,0 @@ -# What is Keychain? - -Keychain is the password management system in macOS, developed by Apple. It was introduced with Mac OS 8.6, and has been included in all subsequent versions of the operating system, now known as macOS. A Keychain can contain various types of data: passwords, private keys, certificates, and secure notes. - -## Notice: -This build of CodeEditKeychain could change at anytime if bugs or breaking changes are found in the module. - -## Usage - -### String - -```swift -let keychain = CodeEditKeychain() -keychain.set("hello world", forKey: "my key") -keychain.get("my key") -``` - -### Boolean - -```swift -let keychain = CodeEditKeychain() -keychain.set(true, forKey: "my key") -keychain.getBool("my key") -``` - -### Data - -```swift -let keychain = CodeEditKeychain() -keychain.set(dataObject, forKey: "my key") -keychain.getData("my key") -``` -### Removing Keys - -```swift -let keychain = CodeEditKeychain() -keychain.delete("my key") -``` - -### Return All Keys - -```swift -let keychain = CodeEditKeychain() -keychain.allKeys // Returns the names of all keys -``` - -### Check if operation was successful - -One can verify if `set`, `delete` and `clear` methods finished successfully by checking their return values. Those methods return `true` on success and `false` on error. - -```swift -if keychain.set("hello world", forKey: "my key") { - // Keychain item is saved successfully -} else { - // Report error -} -``` - -### Setting key prefix - -One can pass a `keyPrefix` argument when initializing a `CodeEditKeychain` object. The string passed in `keyPrefix` argument will be used as a prefix to **all the keys** used in `set`, `get`, `getData` and `delete` methods. Adding a prefix to the keychain keys can be useful in unit tests. This prevents the tests from changing the Keychain keys that are used when the app is launched manually. - -```swift -let keychain = CodeEditKeychain(keyPrefix: "myTestKey_") -keychain.set("hello world", forKey: "hello") // Value will be stored under "myTestKey_hello" key -``` diff --git a/Documentation.docc/Keybindings/KeybindingManager.md b/Documentation.docc/Keybindings/KeybindingManager.md deleted file mode 100644 index c90d412fbf..0000000000 --- a/Documentation.docc/Keybindings/KeybindingManager.md +++ /dev/null @@ -1,31 +0,0 @@ -# ``CodeEdit/KeybindingManager`` - -This module created in order to put all keybindings into single place in code, so it'd be easy to interact, reuse and change keybindings without going through every class and changing code to use other shortcut. It uses `default_keybindings.json` file to store initial set of keybindings. After app launched all keybindings loaded into memory and can be referenced via ``KeybindingManager/named(with:)`` function. - -## Initial setup - -In order to get it working you just need to add `Keybindings` as dependency to your module just like -``` -.target( - name: "WelcomeModule", - dependencies: [ - ...other dependencies - "Keybindings", - ]) -``` - -Keybinding module exists as singleton, so you always can reference Keybindings using `KeybindingManager.shared` - -## Topics - - -### Fetching shortcut - -In order to fetch keybinding you need to call following function with string name ``KeybindingManager/named(with:)`` returning you ``KeyboardShortcutWrapper`` which contains ``KeyboardShortcutWrapper/keyboardShortcut`` which can be passed directly to ``keyboardShortcut``. So the end code would look like `.keyboardShortcut(KeyboardShortcutWrapper.shared.named(with: "copy").keyboardShortcut` - -If shortcut wasn't found by name, it will return fallback shortcut which has following keybinding `Shift + ?` - -### Adding new shortcut - -To add new shortcut you need first to add new row to `default_keybindings.json` file. Make sure you follow other keybindings format. Also check that there's no other keybindings with same ID, -because we use it to identify keybindings later. Once added - you can refer to `Fetching Shortcut` section. It is possible to add new shortcut in runtime via ``KeybindingManager/addNewShortcut(shortcut:name:)`` diff --git a/Documentation.docc/Welcome/Welcome Window.md b/Documentation.docc/Welcome/Welcome Window.md deleted file mode 100644 index dc62a3fddd..0000000000 --- a/Documentation.docc/Welcome/Welcome Window.md +++ /dev/null @@ -1,12 +0,0 @@ -# Welcome Window - -## Topics - -### Views - -- ``WelcomeWindowView`` -- ``WelcomeView`` -- ``WelcomeActionView`` - -- ``RecentProjectsView`` -- ``RecentProjectItem`` diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md new file mode 100644 index 0000000000..da8bf0b653 --- /dev/null +++ b/docs/architecture-decisions.md @@ -0,0 +1,174 @@ +# CodeEdit Architecture Decisions + +Questions that were asked, investigated, and settled, with the measurements that settled them. + +This file exists so [ARCHITECTURE.md](../ARCHITECTURE.md) can stay short. +That guide states the rules; this one records why, and what was rejected. +Read it when you are about to reopen a decision, so you can see whether the reasoning still holds or the ground has moved. + +Each entry names what was measured, because a rule documented with only its conclusion is one argument away from deletion. + +## Targets that stay separate + +**`ShellClient` is one file, and stays its own target** (asked and settled 2026-08-16). +Size is the wrong measure: `ShellClientProtocol` in Core is used by **19 files** across `CESourceControl` and `CELSP` (`GitClient`, `SourceControlManager`, `RegistryManager`, all five package managers), and **none of them imports the implementation**. +Only the app target does, six files, composing it at the root. +The abstraction is load-bearing, not ceremonial. + +What the separate target buys, stated precisely: reaching for the implementation from a feature needs a **manifest edit**, visible in review, rather than an import line inside a file. +It is reviewability, not prevention: import honesty checks that imports are *declared*, so a feature that declared the dependency would pass the audit. +Folding it into the app target is a coherent alternative (the app is the only consumer, and composing platform adapters is a composition-root job); it would cost the manifest-level visibility and nothing else demonstrable. +**`CEWorkspaceFileManager` stays its own target, and must not merge into Core** (asked and settled 2026-08-20). +The inversion is already in place: Core declares `WorkspaceFileProviding` and `WorkspaceFileObserver`, and `CEWorkspaceFileManager.swift:263` is `extension CEWorkspaceFileManager: WorkspaceFileProviding {}`. +Four `CEEditor` files depend on the *protocol* (`EditorRestorer`, `EditorJumpBarMenu`, `EditorLayout+StateRestoration`, the environment key) and **no package imports the implementation**. +Its 22 consumers are app-side, plus 6 test files. +Same shape as `ShellClient` below: contract in Core, adapter in its own target, app composes. +See the I/O norm above for why the merge is worse than it looks. +**`CodeEditDocument` and `CELSP` both stay their own targets** (asked and settled 2026-08-20). +Neither is a leftover, and the two conclusions depend on each other. + +`CEEditor` and `CELSP` reference each other **zero times, in either direction**. +They are siblings. +What keeps them apart is `LanguageServicesProvider`, declared in `CodeEditDocument`, implemented by `CELSP`'s `AppLanguageServicesProvider`, and consumed by `CEEditor` through an environment key. +It even ships a `NoOpLanguageServicesProvider`, so the editor works with no language service at all. +So `CodeEditDocument` is not "the document type plus some bridging": **it is the contract that keeps two features independent.** `CodeFileDocument` imports AppKit, SwiftUI and the editor frameworks, so it cannot live in Core; two features need it, so it cannot live in either. +Its own target is forced, not chosen. + +`CELSP` is not part of the editor either. +Its consumers are the settings UI (installing servers), the utility area (reading logs) and app lifecycle (nothing in `CEEditor` imports it), and **28 of its 78 files are `Registry/`**: package managers, install steps and source parsers for Cargo, NPM, Pip, Go and GitHub. +That is downloading and installing language servers, not editing text. +Folding it into `CEEditor` would make a ~130-file target mixing the two. + +The abstraction is already sound where it counts: `LanguageServer`, `LSPContentCoordinator`, `SemanticTokenHighlightProvider` and `LanguageServerDocumentObjects` are all generic over `LanguageServerDocument`, a protocol requiring only `content`, `languageServerURI` and `getLanguage()`. +Only `LSPService` itself pins the generic to `CodeFileDocument`. +Decoupling that would mean making the service generic and forcing `LSPServiceProtocol` to gain an associated type, breaking its use as an existential for DI, all to delete a five-file target. +A bad trade. +## Keeping I/O out of Core + +**What the norm actually prevents** (asked 2026-08-20): merging `CEWorkspaceFileManager` into Core. +That target holds 50 `FileManager` calls and a complete FSEvents implementation, `FSEventStreamCreate` with a C callback, its own dispatch queue, and start/stop/invalidate/release. +Without this norm the merge looks reasonable, because that target depends on nothing but Core and folding it in removes a target. +With the norm it is obviously wrong: it would put a live filesystem event stream inside the dependency sink all twelve targets rest on, and end Core's filesystem-free tests the same day. +State the norm with this example, not on principle alone. +## The panel contribution seam + +The first extension seam built, kept here in full because it is the worked example the later seams follow. +The reusable pattern is summarised in the guide; the specifics below live with the code as doc comments. + +### Detail + +The navigator, inspector and utility area no longer switch on closed enums (`NavigatorTab`, `InspectorTab`, `UtilityAreaTab`). +Each panel is a list of `WorkspacePanelContribution` values, so a tab is a value, not a case, assembled by one function per panel in `CodeEdit/WorkspaceWindow/WorkspacePanel/PanelContributions.swift`: first-party entries named directly, conditional ones (`InternalDevelopmentInspectorContribution`) as a plain `if`, then extension-provided ones appended through a single adapter call. +The panel that renders the list cannot tell a first-party tab from an extension's. +That indistinguishability is the point; it is what lets a new contribution source arrive without the panel code changing. + +`WorkspacePanelContribution` lives in `CodeEditUI` (`CodeEditModules/Sources/CodeEditUI/WorkspacePanelContribution.swift`), not `CodeEditCore`: a contribution vends a `content: AnyView`, and Core's charter (rule 1, above) forbids UI imports outright. +`CodeEditUI`'s own charter (rule 2) is satisfied too: the protocol needs SwiftUI and nothing else. + +A contribution vends two views. +`content` is the tab itself. +`bottomView` is an optional bar pinned below it. +The navigator's filter field and sort controls are the existing examples. +It defaults to `nil` so a tab with no such bar says nothing about it. +It is a *requirement* rather than something the panel is handed deliberately: it arrived from upstream as a `switch` over the retired tab enum, where every new tab had to remember to add its case, and a package could not contribute one at all because the switch lived app-side. +Vended by the contribution, a tab cannot forget, and where the bar is placed stays the panel's business: pre-Tahoe it insets the tab's own content, and from macOS 26 it spans the panel below the tab bar. + +**A panel's tab list changes at runtime**, so a stored selection can outlive the tab it names: the inspector rebuilds its list when a setting changes, and any panel's list changes when an extension is enabled or disabled. +`Collection.reconcilingSelection(_:)` (alongside the protocol) keeps a selection that still resolves and otherwise falls back to the first tab; `WorkspacePanelView` applies it on appear and on every change to the list. +Without it the panel reads "No Selection" until the user clicks something, because the stored id is stale rather than absent and nothing recovers on its own. +The rule lives in the package rather than the view so it holds for every panel and can be tested without one. + +A contribution's owner follows the same placement rule as everything else in [Where does my code go?](#where-does-my-code-go): a feature that owns a tab vends its own contribution from its package, reading whatever it needs (including settings, through the seam) directly rather than having the app assemble it. +`CESearch`'s `FindNavigatorContribution` (`CodeEditModules/Sources/CESearch/FindNavigatorContribution.swift`) replaced an app-side `FindNavigatorTab` wrapper that existed only to shuttle settings values down. +Once the feature could read its own settings, the wrapper had no reason to exist. +The tab's id is now owned by the same package (`FindNavigatorContribution.tabID`); the app's `PanelTabID.search` references it rather than duplicating the literal, so there is exactly one source of truth even though the app still needs a compile-checked constant to select the tab by. + +`ProjectNavigatorContribution` (`CodeEdit/WorkspaceWindow/NavigatorArea/NavigatorContributions.swift`) stays app-side, not because it is a tab (see the [chrome exemption correction](#where-does-my-code-go)) but because the project navigator has no owning package to move to. +The same holds for the file inspector, the internal-development inspector, and the debug and output utility tabs. +`TerminalUtilityContribution` is the one that should move, to `CETerminal`; it has not because it is coupled to app-side utility-area chrome. + +`CESourceControl` is the second feature package to own a panel tab, after `CESearch`. +`SourceControlNavigatorContribution` and `GitHistoryInspectorContribution` (`CodeEditModules/Sources/CESourceControl/SourceControlNavigator/` and `.../HistoryInspector/`) vend `SourceControlNavigatorView` and `HistoryInspectorView` from the package; the app-side `WorkspaceWindow/NavigatorArea/SourceControlNavigator/` and `WorkspaceWindow/InspectorArea/HistoryInspector/` groups no longer exist. +The relocation is the shape a future one follows: whatever the view needs that the package cannot see becomes a **required** initialiser parameter on the contribution (`WorkspaceNavigator` for the navigator tab, `ActiveEditorState` for the inspector tab) rather than an environment key moved down with it; both are non-optional so a missing injection at the call site (`PanelContributions.swift`) is a compile error, not a silently no-op tab. +Each contribution still owns its own `tabID` constant, and the app's `PanelTabID.sourceControl` / `.gitHistory` reference it rather than duplicating the literal, same as `PanelTabID.search`. +A third relocation onto this shape is expected. + +Extensions are the third contribution source. +`ExtensionPanelContribution` (`CodeEdit/WorkspaceWindow/WorkspacePanel/ExtensionPanelContribution.swift`) is the **only** app file, outside the pre-existing extension-management UI under `AuxiliaryWindows/Extensions/`, that may name `AppExtensionIdentity` or `ResolvedSidebar`. +Confining ExtensionKit's vocabulary to this one adapter is what let the app-side panel-contribution list above be written without an ExtensionKit import in sight, and is what would let a second, non-ExtensionKit contribution source arrive later without touching the panels. + +## What the Core charter has produced + +The friction this produces is usually the rule working. +Four worked examples already in this codebase: `FileIcon` is keyed on `URL` rather than a domain type, so it needs neither `CodeEditCore` nor a charter exception (see rule 2 below); `WorkspacePanelContribution` was shaped to need only SwiftUI, so it lives in `CodeEditUI` (`CodeEditModules/Sources/CodeEditUI/WorkspacePanelContribution.swift`); fuzzy matching's concurrency helper was rewritten over `withTaskGroup` rather than admit `CollectionConcurrencyKit` (below); and `ActiveTheme`, the active light/dark theme, observed by the editor and terminal, lives *in* Core, because `ObservableObject` comes from Combine rather than SwiftUI. +That last one is worth remembering: the charter forbids `SwiftUI`/`AppKit`/`Cocoa` specifically, so observation is available in Core and an "it needs to be observable, therefore it needs SwiftUI" argument is simply false. + +The counter-example people will cite: `TextEditingSettings` and `TerminalSettings.Font` carry `NSFont.Weight`, which forces them out of Core. +That is the rule flagging a presentation type inside a settings model, not the rule obstructing a reasonable design. +Storing the weight as a `Double` and converting at the presentation boundary would make both structs Core-eligible with no rule change. +## Singletons that remain + +Recorded 2026-08-16. + +Eight singletons remain. +None is load-bearing; each is listed with the scope it actually has. + +| Singleton | True scope | Note | +| --- | --- | --- | +| `ThemeModel` | process | already takes its settings store via `configure(_:)` at launch | +| `FeedbackModel` | process | ditto | +| `SearchSettingsModel` | process | ditto | +| `ExtensionManager` | process | | +| `ExtensionDiscovery` | process | | +| `InternalDevelopmentOutputSource` | process | debug-only | +| `EditorStateRestoration` | process | a GRDB database; `nonisolated(unsafe)` and optional | +| `TerminalCache` | **workspace** | see below | + +The first seven are process-scoped services that simply have not moved to `AppDependencies`; doing so is mechanical and changes no behaviour. + +`TerminalCache` is different, and is the one worth fixing rather than relocating. +It is a process-global `[UUID: CELocalShellTerminalView]` holding views that belong to a workspace window. +Eviction is per-terminal only, and nothing clears it when a workspace or window closes, so two open projects share one bag of live terminal views. +It works because UUIDs do not collide, but the lifetime is wrong, and a wrong lifetime surfaces as a leak rather than as a compile error. + +## Document isolation is bridged, not solved + +`CodeFileDocument` moved into `CodeEditDocument` during the package extraction, which put it under Swift 6 strict concurrency for the first time. +That surfaced six pre-existing isolation errors, all in code that is byte-identical on `main` and compiles silently there. + +`NSDocument` is main-actor isolated, but declares `read(from:ofType:)` and `presentedItemDidChange()` nonisolated, because AppKit may call them off the main thread. +Both touch main-actor document state. +Three things now hold that together, and none of them is a static guarantee: + +1. `canConcurrentlyReadDocuments(ofType:)` is overridden to return `false`, pinning AppKit's default so its own reads stay on the main thread. Returning `true` would make that half unsound with no compile error. +2. All four sites that touch main-actor state from a nonisolated override branch on `Thread.isMainThread` and use `MainActor.assumeIsolated` on the main-thread side: `read(from:ofType:)`, `presentedItemDidChange()`, `notifyLSPDidOpen()`/`notifyLSPDidClose(_:)`, and `registerContentChangeUndo`. The pin is not load-bearing on its own, because it says nothing about an in-process caller constructing a document off the main actor, which has happened here before and trapped a bare `assumeIsolated`. +3. Two of the four block rather than hop. `read(from:ofType:)` must, because `NSDocument` requires the document loaded by the time it returns; `presentedItemDidChange()` must, or repeated change notifications pile up. Both therefore carry a `DispatchQueue.main.sync`, whose safety rests on no caller blocking the main thread while triggering an off-main read. Nothing enforces that. + +This is accepted as a bridge so the extraction can land, not as the end state. +The real problem is that the type mixes main-actor UI state (`content` is an `NSTextStorage` that SwiftUI observes) with an I/O lifecycle driven from arbitrary threads. +Separating those, so decoding produces a `Sendable` value that a single main-actor step installs, removes all three props at once. +That is a redesign of the document's state ownership and is deliberately deferred. + +The general lesson is worth stating separately, because it applies to every future extraction: **moving a file into `CodeEditModules` is also a Swift 6 migration of that file.** +The app target is `SWIFT_VERSION = 5.0` with no `SWIFT_STRICT_CONCURRENCY` setting, so it defaults to `minimal`; the package is `swift-tools-version: 6.0`, so every target defaults to Swift 6 language mode. +Code that compiled without complaint for years can arrive in a package with a dozen errors, none of them regressions. + +## The app is not sandboxed + +`ENABLE_APP_SANDBOX = NO` on all five app and app-hosted-test build configurations, and +`CodeEdit.entitlements` carries no `com.apple.security.app-sandbox` key. +This is deliberate and is the project's long-standing configuration, not a workaround left in place. + +The App Sandbox blocks `Process` from spawning subprocesses, and CodeEdit's core features are built on exactly that. +`ShellClient` spawns `/bin/zsh`; `CETerminal` (`Shell`, `CELocalShellTerminalView`) runs the user's shell; `RepositoryCloner` and the rest of source control shell out to `git`, which itself shims through `xcrun`. +Sandboxed, all of these fail with `xcrun: error: cannot be used within an App Sandbox.` + +The history is worth recording because it has already been changed once by accident. +Community PR #2147 (commit `78c3be9c`, 2025-12-12) enabled the sandbox as part of an unrelated deprecations and memory-leak fix, which broke git, LSP, the terminal, and package installs. +Commit `a2fff0c9` reverted to the pre-#2147 configuration and restored the `com.apple.security.cs.allow-jit` and `com.apple.security.cs.disable-library-validation` exceptions it had removed. +Anyone tempted to enable the sandbox should read this section first: it is not a build-setting toggle. + +Two consequences follow. +Mac App Store distribution is out of scope, since the store requires sandboxing, and getting there would mean rearchitecting every subprocess path rather than flipping a flag. +And the security-scoped bookmark handling added for recents (`WorkspaceFactory`, `Workspace.tearDown`) is a no-op while unsandboxed, because `startAccessingSecurityScopedResource()` returns `false`; it is kept so the code stays correct if this decision is ever revisited. +