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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ enum WorkspaceProjectionComposition {
refreshGit: { [weak model] in
await model?.gitFeatureIfActive?.refreshGitFromMetadataChange()
},
updateHistoryVisibilityRules: { [weak model] rules in
guard let feature = await model?.activateHistoryModule() else { return }
await feature.updateVisibilityRules(rules.localHistoryRules)
updateHistoryVisibilityRules: { [weak model] _ in
// The ignore rules live beside project visibility, so read both from settings.
guard let model, let feature = await model.activateHistoryModule() else { return }
await feature.updateVisibilityRules(model.settings.localHistoryRules)
},
onSnapshotLoaded: { [weak model] snapshot, isInitialLoad in
guard let model else { return }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Combine
import LitheLocalHistoryModule

/// Owns storage feedback for the settings page, including failed clears and retries.
@MainActor
final class LocalHistoryStorageSettingsModel: ObservableObject {
@Published private(set) var usage: LocalHistoryUsage?
@Published private(set) var isClearing = false
@Published private(set) var clearFailed = false

func refreshUsage(using readUsage: @MainActor () async -> LocalHistoryUsage?) async {
usage = await readUsage()
}

func clear(
using clearHistory: @MainActor () async -> Bool,
readUsage: @MainActor () async -> LocalHistoryUsage?
) async {
guard !isClearing else { return }
isClearing = true
clearFailed = false
defer { isClearing = false }
let cleared = await clearHistory()
await refreshUsage(using: readUsage)
clearFailed = !cleared
}
}
107 changes: 105 additions & 2 deletions macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,46 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore {
let relocated: Bool
}

struct HistoryUsagePayload: Decodable, Sendable {
let usedBytes: Int64
let limitBytes: Int64
}

struct HistoryIgnorePreviewPayload: Decodable, Sendable {
struct RegexError: Decodable, Sendable, Equatable {
let index: Int
let message: String
}

enum Match: Decodable, Sendable, Equatable {
case hidden
case pattern(String)
case regex(index: Int)

private enum CodingKeys: String, CodingKey { case kind, pattern, index }

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .kind) {
case "hidden": self = .hidden
case "pattern": self = .pattern(try container.decode(String.self, forKey: .pattern))
case "regex": self = .regex(index: try container.decode(Int.self, forKey: .index))
case let kind:
throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown match \(kind)")
}
}
}

let regexErrors: [RegexError]
let builtInIgnorePatterns: [String]
let matched: Match?
let gitIgnored: Bool?
}

private struct HistoryClearPayload: Decodable, Sendable {
let cleared: Bool
}

struct MarkdownRenderPayload: Decodable, Sendable {
let html: String
}
Expand Down Expand Up @@ -1603,6 +1643,25 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore {
let pruneExpired: Bool
let hiddenDirectoryNames: [String]
let hiddenFilePatterns: [String]
let ignoreRules: HistoryIgnoreRules
let storageLimitBytes: Int64?
}

private struct HistoryIgnoreRules: Encodable {
let pathPatterns: [String]
let pathRegexes: [String]
}

private struct HistoryIgnorePreviewRequest: Encodable {
let path: String?
let ignoreRules: HistoryIgnoreRules
let hiddenFilePatterns: [String]
let workspaceRoot: String?
}

private struct HistoryStorageRequest: Encodable {
let storageRoot: String
var storageLimitBytes: Int64?
}

private struct HistoryEntriesRequest: Encodable {
Expand Down Expand Up @@ -2678,7 +2737,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore {
content: String?,
pruneExpired: Bool,
hiddenDirectoryNames: [String],
hiddenFilePatterns: [String]
hiddenFilePatterns: [String],
ignorePatterns: [String] = [],
ignoreRegexes: [String] = [],
storageLimitBytes: Int64? = nil
) -> HistoryEntryPayload? {
execute(
command: "history.record",
Expand All @@ -2690,11 +2752,52 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore {
content: content,
pruneExpired: pruneExpired,
hiddenDirectoryNames: hiddenDirectoryNames,
hiddenFilePatterns: hiddenFilePatterns
hiddenFilePatterns: hiddenFilePatterns,
ignoreRules: HistoryIgnoreRules(
pathPatterns: ignorePatterns,
pathRegexes: ignoreRegexes
),
storageLimitBytes: storageLimitBytes
)
)
}

func historyIgnorePreview(
path: String?,
ignorePatterns: [String],
ignoreRegexes: [String],
hiddenFilePatterns: [String],
workspaceURL: URL? = nil
) -> HistoryIgnorePreviewPayload? {
execute(
command: "history.ignorePreview",
payload: HistoryIgnorePreviewRequest(
path: path,
ignoreRules: HistoryIgnoreRules(
pathPatterns: ignorePatterns,
pathRegexes: ignoreRegexes
),
hiddenFilePatterns: hiddenFilePatterns,
workspaceRoot: workspaceURL?.standardizedFileURL.path
)
)
}

func historyUsage(storageURL: URL, limitBytes: Int64? = nil) -> HistoryUsagePayload? {
execute(
command: "history.usage",
payload: HistoryStorageRequest(storageRoot: storageURL.standardizedFileURL.path, storageLimitBytes: limitBytes)
)
}

func historyClear(storageURL: URL) -> Bool {
let response: HistoryClearPayload? = execute(
command: "history.clear",
payload: HistoryStorageRequest(storageRoot: storageURL.standardizedFileURL.path)
)
return response?.cleared == true
}

func historyEntries(
at workspaceURL: URL,
storageURL: URL,
Expand Down
33 changes: 32 additions & 1 deletion macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable {
at: workspaceURL, storageURL: storageURL, relativePath: relativePath,
reason: reason.rawValue, content: content, pruneExpired: pruneExpired,
hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames,
hiddenFilePatterns: visibilityRules.hiddenFilePatterns
hiddenFilePatterns: visibilityRules.hiddenFilePatterns,
ignorePatterns: visibilityRules.ignorePatterns,
ignoreRegexes: visibilityRules.ignoreRegexes,
storageLimitBytes: visibilityRules.storageLimitBytes
).map(Self.makePayload)
}

Expand All @@ -24,6 +27,34 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable {
func content(at storageURL: URL, contentPath: String) -> String? { core.historyContent(storageURL: storageURL, contentPath: contentPath)?.text }
func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { core.historyRelocate(storageURL: storageURL, sourcePath: sourcePath, destinationPath: destinationPath) }

func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? {
core.historyUsage(storageURL: storageURL, limitBytes: limitBytes).map { LocalHistoryUsage(usedBytes: $0.usedBytes, limitBytes: $0.limitBytes) }
}

func clear(at storageURL: URL) -> Bool { core.historyClear(storageURL: storageURL) }

func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? {
guard let value = core.historyIgnorePreview(
path: path,
ignorePatterns: rules.ignorePatterns,
ignoreRegexes: rules.ignoreRegexes,
hiddenFilePatterns: rules.hiddenFilePatterns,
workspaceURL: workspaceURL
) else { return nil }
let matched: LocalHistoryIgnoreMatch? = switch value.matched {
case nil: nil
case .hidden: .hidden
case .pattern(let pattern): .pattern(pattern)
case .regex(let index): .regex(index: index)
}
return LocalHistoryIgnorePreview(
regexErrors: Dictionary(value.regexErrors.map { ($0.index, $0.message) }, uniquingKeysWith: { first, _ in first }),
builtInIgnorePatterns: value.builtInIgnorePatterns,
matched: matched,
gitIgnored: value.gitIgnored
)
}

private static func makePayload(_ value: RustCoreBridge.HistoryEntryPayload) -> LocalHistoryEntryPayload {
LocalHistoryEntryPayload(id: value.id, timestamp: value.timestamp, relativePath: value.relativePath, reason: value.reason, contentPath: value.contentPath, byteCount: value.byteCount)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ extension AppModel {
await historyModuleCoordinator.activate()
}

func localHistoryIgnorePreview(path: String?, rules: LocalHistoryVisibilityRules) async -> LocalHistoryIgnorePreview? {
await activateHistoryModule()?.previewIgnoreRules(path: path, rules: rules)
}

/// Nil when no project is open.
func localHistoryUsage() async -> LocalHistoryUsage? {
guard workspaceURL != nil else { return nil }
return await activateHistoryModule()?.storageUsage()
}

func clearLocalHistory() async -> Bool {
guard workspaceURL != nil, let feature = await activateHistoryModule() else { return false }
return await feature.clearHistory()
}

func withHistoryModule(_ action: @escaping @MainActor (ProjectHistoryFeatureModel) async -> Void) {
historyModuleCoordinator.perform(action)
}
Expand Down
10 changes: 9 additions & 1 deletion macos/Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling {
private var featureObservationBinder: AppModelObservationBinder?
private var fileVisibilityRulesObserverID: UUID?
private var searchExclusionsObservation: AnyCancellable?
private var localHistoryIgnoreRulesObservation: AnyCancellable?
private var livePluginObservation: AnyCancellable?
private var requestProjectOpen: ((URL) -> Void)?
private var requestProjectOpenAtPlacement: ((URL, ProjectOpenPlacement) -> Void)?
Expand Down Expand Up @@ -264,7 +265,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling {
store: moduleCapabilityStore,
workspace: { [weak self] in self?.workspaceURL },
settings: { [weak self] in
self?.settings.fileVisibilityRules.localHistoryRules
self?.settings.localHistoryRules
?? LocalHistoryVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [])
},
files: { [weak self] in self?.projectFiles ?? [] },
Expand Down Expand Up @@ -589,6 +590,12 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling {
guard let self else { return }
self.workspaceFeature.updateVisibilityRules(self.settings.fileVisibilityRules)
}
localHistoryIgnoreRulesObservation = settings.localHistoryIgnoreRulesChanges.sink { [weak self] in
// An inactive module reads the rules when it opens the workspace.
guard let self, let feature = self.projectHistoryFeatureIfActive else { return }
let rules = self.settings.localHistoryRules
Task { await feature.updateVisibilityRules(rules) }
}
searchExclusionsObservation = settings.searchExclusionsChanges.sink { [weak self] in
guard let self, let root = self.workspaceURL else { return }
self.searchFeatureIfActive?.invalidateIndex(at: root, visibilityRules: self.settings.searchEverywhereVisibilityRules.searchRules)
Expand Down Expand Up @@ -732,6 +739,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling {
self.fileVisibilityRulesObserverID = nil
}
searchExclusionsObservation = nil
localHistoryIgnoreRulesObservation = nil
livePluginObservation = nil
await shutdownModuleRuntime()
moduleSessionCoordinator?.stopObserving()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,17 @@ extension FileVisibilityRules {
)
}
}

extension AppSettings {
/// Project visibility plus the rules that only keep files out of local history.
var localHistoryRules: LocalHistoryVisibilityRules {
let visibility = fileVisibilityRules
return LocalHistoryVisibilityRules(
hiddenDirectoryNames: visibility.hiddenDirectoryNames,
hiddenFilePatterns: visibility.hiddenFilePatterns,
ignorePatterns: localHistoryIgnorePatterns,
ignoreRegexes: localHistoryIgnoreRegexes,
storageLimitBytes: Int64(localHistoryStorageLimitMegabytes) * 1024 * 1024
)
}
}
47 changes: 47 additions & 0 deletions macos/Sources/Lithe/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ final class AppSettings: ObservableObject {
static let legacyHiddenFilePatterns = "settings.hiddenFilePatterns"
static let searchExcludedPathPatterns = "settings.searchExcludedPathPatterns"
static let searchExclusionsEnabled = "settings.searchExclusionsEnabled"
static let localHistoryIgnorePatterns = "settings.localHistoryIgnorePatterns"
static let localHistoryIgnoreRegexes = "settings.localHistoryIgnoreRegexes"
static let localHistoryStorageLimitMegabytes = "settings.localHistoryStorageLimitMegabytes"
static let gitExecutable = "settings.gitExecutable"
static let gitUseCredentialHelper = "settings.gitUseCredentialHelper"
static let gitFetchOptions = "settings.gitFetchOptions"
Expand Down Expand Up @@ -194,6 +197,40 @@ final class AppSettings: ObservableObject {
if oldValue != searchExclusionsEnabled { searchExclusionsDidChange.send() }
}
}
/// Kept out of local history only; the project tree still shows these files.
/// Holds the user's own globs plus `!**/name/` for removed built-in folder
/// rules, so built-ins added later still apply.
@Published var localHistoryIgnorePatterns: [String] {
didSet {
defaults.set(localHistoryIgnorePatterns, forKey: Key.localHistoryIgnorePatterns)
if oldValue != localHistoryIgnorePatterns { localHistoryIgnoreRulesDidChange.send() }
}
}
@Published var localHistoryIgnoreRegexes: [String] {
didSet {
defaults.set(localHistoryIgnoreRegexes, forKey: Key.localHistoryIgnoreRegexes)
if oldValue != localHistoryIgnoreRegexes { localHistoryIgnoreRulesDidChange.send() }
}
}
static let defaultLocalHistoryStorageLimitMegabytes = 100
static let localHistoryStorageLimitRange = 50...5000
/// Accept a whole nonnegative number, optionally followed by the displayed unit.
nonisolated static func parseLocalHistoryStorageLimitMegabytes(_ input: String) -> Int? {
var number = input.trimmingCharacters(in: .whitespacesAndNewlines)
if number.uppercased().hasSuffix("MB") {
number = String(number.dropLast(2)).trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) }) else { return nil }
return Int(number)
}

/// Per-project local history cap in MB (1 MB = 1024 × 1024 bytes).
@Published var localHistoryStorageLimitMegabytes: Int {
didSet {
defaults.set(localHistoryStorageLimitMegabytes, forKey: Key.localHistoryStorageLimitMegabytes)
if oldValue != localHistoryStorageLimitMegabytes { localHistoryIgnoreRulesDidChange.send() }
}
}
let gitExecutionPreferences = GitExecutionPreferences()
@Published var gitExecutable: String {
didSet { defaults.set(gitExecutable, forKey: Key.gitExecutable); updateGitExecutionPreferences() }
Expand Down Expand Up @@ -457,6 +494,11 @@ final class AppSettings: ObservableObject {
searchExcludedPathPatterns = defaults.stringArray(forKey: Key.searchExcludedPathPatterns) ?? []
// Existing saved exclusions stay active when upgrading from the inline editor.
searchExclusionsEnabled = defaults.object(forKey: Key.searchExclusionsEnabled) as? Bool ?? true
localHistoryIgnorePatterns = defaults.stringArray(forKey: Key.localHistoryIgnorePatterns) ?? []
localHistoryIgnoreRegexes = defaults.stringArray(forKey: Key.localHistoryIgnoreRegexes) ?? []
localHistoryStorageLimitMegabytes = (defaults.object(forKey: Key.localHistoryStorageLimitMegabytes) as? Int)
.map { min(max($0, Self.localHistoryStorageLimitRange.lowerBound), Self.localHistoryStorageLimitRange.upperBound) }
?? Self.defaultLocalHistoryStorageLimitMegabytes
gitExecutable = defaults.string(forKey: Key.gitExecutable) ?? ""
gitUseCredentialHelper = defaults.object(forKey: Key.gitUseCredentialHelper) as? Bool ?? true
var savedFetchOptions = defaults.data(forKey: Key.gitFetchOptions)
Expand Down Expand Up @@ -609,6 +651,8 @@ final class AppSettings: ObservableObject {

private let searchExclusionsDidChange = PassthroughSubject<Void, Never>()
var searchExclusionsChanges: AnyPublisher<Void, Never> { searchExclusionsDidChange.eraseToAnyPublisher() }
private let localHistoryIgnoreRulesDidChange = PassthroughSubject<Void, Never>()
var localHistoryIgnoreRulesChanges: AnyPublisher<Void, Never> { localHistoryIgnoreRulesDidChange.eraseToAnyPublisher() }

/// Search Everywhere globs never change other search clients or project visibility.
var searchEverywhereVisibilityRules: FileVisibilityRules {
Expand Down Expand Up @@ -664,6 +708,9 @@ final class AppSettings: ObservableObject {
hiddenPathPatterns = []
searchExcludedPathPatterns = []
searchExclusionsEnabled = true
localHistoryIgnorePatterns = []
localHistoryIgnoreRegexes = []
localHistoryStorageLimitMegabytes = Self.defaultLocalHistoryStorageLimitMegabytes
gitFetchOptions = GitFetchOptions()
gitExecutable = ""
gitUseCredentialHelper = true
Expand Down
Loading
Loading