Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
89ccc14
Hiding source control navigator when source control is disabled in Se…
austincondiff Oct 29, 2024
78b8184
Hiding History Inspector when source control is disabled in Settings.
austincondiff Oct 29, 2024
e58ab76
Hiding Source Control menu when source control is disabled in Settings.
austincondiff Oct 29, 2024
7adc787
If enableSourceControl or refreshStatusLocally setting is false then …
austincondiff Oct 29, 2024
5eb0f7f
Hiding branch picker in the toolbar when source control is disabled i…
austincondiff Oct 29, 2024
b7c9797
Enabled fetch and refresh server status automatically setting. When d…
austincondiff Oct 31, 2024
409c4ce
Author name, author email, and prefer rebase when pulling settings us…
austincondiff Nov 1, 2024
7ddb5e8
Default branch setting uses the users global git config instead of Co…
austincondiff Nov 1, 2024
ae6ba40
Show merge commits per file log setting now shows merge commits if en…
austincondiff Nov 1, 2024
241ebbd
Fixed swiftlint error
austincondiff Nov 1, 2024
996be19
Added Ignored Files list in source control settings. Refactored searc…
austincondiff Nov 2, 2024
52b03ef
Fixed a small race condition
austincondiff Nov 2, 2024
528fe57
Merge branch 'main' into source-control-settings
austincondiff Nov 2, 2024
90934f2
Added type to hasAppeared variable in SourceControlGitView.
austincondiff Nov 2, 2024
9635fe5
Renamed `enableSourceControl` to `sourceControlIsEnabled`. Added docu…
austincondiff Nov 16, 2024
71ee6c6
Ignored files can be changed while preserving comments and white spac…
austincondiff Nov 25, 2024
dbf88a9
Merge branch 'main' into source-control-settings
austincondiff Nov 25, 2024
23148b3
Fixed SwiftLint errors
austincondiff Nov 25, 2024
eab7301
Ignore pattern reorder fixes
austincondiff Nov 26, 2024
b31e07d
Writing relative url to gitconfig upon adding first pattern
austincondiff Nov 26, 2024
284df2e
Cleaned up unecessary function
austincondiff Nov 26, 2024
dbf0929
Trying to fix test
austincondiff Nov 26, 2024
0565305
Dynamically resolve gitignore file path.
austincondiff Nov 30, 2024
93b0be5
Fixing test
austincondiff Nov 30, 2024
464bdea
Added source control settings header. Moved feature icon into new vie…
austincondiff Dec 2, 2024
24658a2
Moved tabs below source control toggle
austincondiff Dec 3, 2024
38cd1f0
Fixed PR issue
austincondiff Dec 4, 2024
35be9a9
PR issues, added documentation
austincondiff Dec 8, 2024
97bbefb
Marked IgnorePatternModel with @MainActor. More documentation. Refact…
austincondiff Dec 8, 2024
a446a18
Adding main actor to savePatterns function in IgnorePatternModel per …
austincondiff Dec 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
//
// InspectorAreaView.swift
// CodeEdit
//
// Created by Austin Condiff on 3/21/22.
//

import SwiftUI

struct InspectorAreaView: View {
Expand Down
8 changes: 6 additions & 2 deletions CodeEdit/Features/Settings/Models/GlobPattern.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

import Foundation

/// A simple model that associates a UUID with a glob pattern string.
///
/// 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 {
/// Ephimeral UUID used to track its representation in the UI
/// Ephemeral UUID used to uniquely identify this instance in the UI
var id = UUID()

/// The Glob Pattern to render
/// The Glob Pattern string
var value: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@

import Foundation

/// A model to manage Git ignore patterns for a file, including loading, saving, and monitoring changes.
@MainActor
class IgnorePatternModel: ObservableObject {
/// Indicates whether patterns are currently being loaded from the Git ignore file.
@Published var loadingPatterns: Bool = false

/// A collection of Git ignore patterns being managed by this model.
@Published var patterns: [GlobPattern] = [] {
didSet {
if !loadingPatterns {
Expand All @@ -18,11 +23,19 @@ class IgnorePatternModel: ObservableObject {
}
}
}

/// Tracks the selected patterns by their unique identifiers (UUIDs).
@Published var selection: Set<UUID> = []

/// A client for interacting with the Git configuration.
private let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient)

/// A file system monitor for detecting changes to the Git ignore file.
private var fileMonitor: DispatchSourceFileSystemObject?

/// Task tracking the current save operation
private var savingTask: Task<Void, Never>?

init() {
Task {
try? await startFileMonitor()
Expand All @@ -31,19 +44,23 @@ class IgnorePatternModel: ObservableObject {
}

deinit {
stopFileMonitor()
Task { @MainActor [weak self] in
self?.stopFileMonitor()
}
}

/// Resolves the URL for the Git ignore file.
/// - Returns: The resolved `URL` for the Git ignore file.
private func gitIgnoreURL() async throws -> URL {
let excludesfile = try await gitConfig.get(key: "core.excludesfile") ?? ""
if !excludesfile.isEmpty {
if excludesfile.starts(with: "~/") {
let relativePath = String(excludesfile.dropFirst(2)) // Remove "~/"
let excludesFile = try await gitConfig.get(key: "core.excludesfile") ?? ""
if !excludesFile.isEmpty {
if excludesFile.starts(with: "~/") {
let relativePath = String(excludesFile.dropFirst(2)) // Remove "~/"
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(relativePath)
} else if excludesfile.starts(with: "/") {
return URL(fileURLWithPath: excludesfile) // Absolute path
} else if excludesFile.starts(with: "/") {
return URL(fileURLWithPath: excludesFile) // Absolute path
} else {
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesfile)
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesFile)
}
} else {
let defaultPath = ".gitignore_global"
Expand All @@ -53,6 +70,7 @@ class IgnorePatternModel: ObservableObject {
}
}

/// Starts monitoring the Git ignore file for changes.
private func startFileMonitor() async throws {
let fileURL = try await gitIgnoreURL()
let fileDescriptor = open(fileURL.path, O_EVTONLY)
Expand All @@ -65,9 +83,7 @@ class IgnorePatternModel: ObservableObject {
)

source.setEventHandler {
Task {
await self.loadPatterns()
}
Task { await self.loadPatterns() }
}

source.setCancelHandler {
Expand All @@ -79,62 +95,66 @@ class IgnorePatternModel: ObservableObject {
source.resume()
}

/// Stops monitoring the Git ignore file.
private func stopFileMonitor() {
fileMonitor?.cancel()
fileMonitor = nil
}

/// Loads patterns from the Git ignore file into the `patterns` property.
func loadPatterns() async {
await MainActor.run { loadingPatterns = true } // Ensure `loadingPatterns` is updated on the main thread
loadingPatterns = true

do {
let fileURL = try await gitIgnoreURL()
guard FileManager.default.fileExists(atPath: fileURL.path) else {
await MainActor.run {
patterns = []
loadingPatterns = false // Update on the main thread
}
patterns = []
loadingPatterns = false
return
}

if let content = try? String(contentsOf: fileURL) {
let parsedPatterns = content.split(separator: "\n")
patterns = content.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && !$0.starts(with: "#") }
.map { GlobPattern(value: String($0)) }

await MainActor.run {
patterns = parsedPatterns // Update `patterns` on the main thread
loadingPatterns = false // Ensure `loadingPatterns` is updated on the main thread
}
loadingPatterns = false
} else {
await MainActor.run {
patterns = []
loadingPatterns = false
}
}
} catch {
print("Error loading patterns: \(error)")
await MainActor.run {
patterns = []
loadingPatterns = false
}
} catch {
print("Error loading patterns: \(error)")
patterns = []
loadingPatterns = false
}
}

/// Retrieves the pattern associated with a specific UUID.
/// - Parameter id: The UUID of the pattern to retrieve.
/// - Returns: The matching `GlobPattern`, if found.
func getPattern(for id: UUID) -> GlobPattern? {
return patterns.first(where: { $0.id == id })
}

/// Saves the current patterns back to the Git ignore file.
@MainActor
func savePatterns() {
Task {
// Cancel the existing task if it exists
savingTask?.cancel()

// Start a new task for saving patterns
savingTask = Task {
stopFileMonitor()
defer { Task { try? await startFileMonitor() } }
defer {
savingTask = nil // Clear the task when done
Task { try? await startFileMonitor() }
}

do {
let fileURL = try await gitIgnoreURL()
guard let fileContent = try? String(contentsOf: fileURL) else {
writeAllPatterns()
await writeAllPatterns()
return
}

Expand All @@ -156,6 +176,7 @@ class IgnorePatternModel: ObservableObject {
}
}

/// Maps lines to patterns and non-pattern lines (e.g., comments or whitespace).
private func mapLines(_ lines: [String]) -> ([String: Int], [(line: String, index: Int)]) {
var patternToLineIndex: [String: Int] = [:]
var nonPatternLines: [(line: String, index: Int)] = []
Expand All @@ -172,6 +193,7 @@ class IgnorePatternModel: ObservableObject {
return (patternToLineIndex, nonPatternLines)
}

/// Extracts global comments from the non-pattern lines.
private func extractGlobalComments(
_ nonPatternLines: [(line: String, index: Int)],
_ patternToLineIndex: [String: Int]
Expand All @@ -180,6 +202,7 @@ class IgnorePatternModel: ObservableObject {
return globalComments.map(\.line)
}

/// Reorders patterns while preserving associated comments and whitespace.
private func reorderPatterns(
_ globalCommentLines: [String],
_ patternToLineIndex: [String: Int],
Expand Down Expand Up @@ -227,22 +250,22 @@ class IgnorePatternModel: ObservableObject {
return reorderedLines
}

private func writeAllPatterns() {
Task {
do {
let fileURL = try await gitIgnoreURL()
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}

let content = patterns.map(\.value).joined(separator: "\n")
try content.write(to: fileURL, atomically: true, encoding: .utf8)
} catch {
print("Failed to write all patterns: \(error)")
/// Writes all patterns to the Git ignore file.
private func writeAllPatterns() async {
do {
let fileURL = try await gitIgnoreURL()
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}

let content = patterns.map(\.value).joined(separator: "\n")
try content.write(to: fileURL, atomically: true, encoding: .utf8)
} catch {
print("Failed to write all patterns: \(error)")
}
}

/// Cleans up extra whitespace from lines.
private func cleanUpWhitespace(in lines: [String]) -> [String] {
var cleanedLines: [String] = []
var previousLineWasBlank = false
Expand All @@ -269,12 +292,13 @@ class IgnorePatternModel: ObservableObject {
return cleanedLines
}

@MainActor
/// Adds a new, empty pattern to the list of patterns.
func addPattern() {
patterns.append(GlobPattern(value: ""))
}

@MainActor
/// Removes the specified patterns from the list of patterns.
/// - Parameter selection: The set of UUIDs for the patterns to remove. If `nil`, no patterns are removed.
func removePatterns(_ selection: Set<UUID>? = nil) {
let patternsToRemove = selection?.compactMap { getPattern(for: $0) } ?? []
patterns.removeAll { patternsToRemove.contains($0) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ struct SourceControlGitView: View {
}
}
.onAppear {
// Intentionally using an onAppear with a Task instead of just a .task modifier.
// When we did this it was executing too often.
Task {
authorName = try await gitConfig.get(key: "user.name", global: true) ?? ""
authorEmail = try await gitConfig.get(key: "user.email", global: true) ?? ""
Expand Down
29 changes: 4 additions & 25 deletions CodeEdit/Utils/Limiter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
// TODO: Look into improving this API by using async by default so `Task` isn't needed when used.
enum Limiter {
// Keep track of debounce timers and throttle states
private static var debounceTimers: [AnyHashable: AnyCancellable] = [:]
private static var debounceTimers: [AnyHashable: Timer] = [:]
private static var throttleLastExecution: [AnyHashable: Date] = [:]

/// Debounces an action with a specified duration and identifier.
Expand All @@ -21,31 +21,10 @@ enum Limiter {
/// - action: The action to be executed after the debounce period.
static func debounce(id: AnyHashable, duration: TimeInterval, action: @escaping () -> Void) {
// Cancel any existing debounce timer for the given ID
debounceTimers[id]?.cancel()

debounceTimers[id]?.invalidate()
// Start a new debounce timer for the given ID
debounceTimers[id] = Timer.publish(every: duration, on: .main, in: .common)
.autoconnect()
.first()
.sink { _ in
action()
debounceTimers[id] = nil
}
}

/// Throttles an action with a specified duration and identifier.
/// - Parameters:
/// - id: A unique identifier for the throttled action.
/// - duration: The throttle duration in seconds.
/// - action: The action to be executed after the throttle period.
static func throttle(id: AnyHashable, duration: TimeInterval, action: @escaping () -> Void) {
// Check the time of the last execution for the given ID
if let lastExecution = throttleLastExecution[id], Date().timeIntervalSince(lastExecution) < duration {
return // Skip this call if it's within the throttle duration
debounceTimers[id] = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in
action()
}

// Update the last execution time and perform the action
throttleLastExecution[id] = Date()
action()
}
}
2 changes: 1 addition & 1 deletion CodeEdit/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ struct WorkspaceView: View {
}
}
.onChange(of: sourceControlIsEnabled) { newValue in
if !newValue {
if newValue {
Task {
await sourceControlManager.refreshCurrentBranch()
}
Expand Down