diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index d904763d6..4581e8996 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1597,6 +1597,10 @@ final class AppModel: ObservableObject, Identifiable { } func applyGitLogFilter(_ query: String) async { + await applyGitLogFilter(GitLogQuery.parse(query)) + } + + func applyGitLogFilter(_ query: GitLogQuery) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.applyGitLogFilter(query) } diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 5551e535c..32e786432 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -20,22 +20,21 @@ struct GitGraphView: View { let showCommitDecorations: Bool let actions: GitGraphRowActions - private let rowHeight: CGFloat = 39 + private let rowHeight: CGFloat = 30 var body: some View { LazyVStack(spacing: 0) { - ForEach(stripedRows, id: \.row.id) { stripedRow in + ForEach(visibleRows) { row in GitGraphRowView( - row: stripedRow.row, - graphWidth: graphWidth, + row: row, + graphWidth: graphWidth(for: row), rowHeight: rowHeight, - isSelected: selectedHash == stripedRow.row.commit.hash, + isSelected: selectedHash == row.commit.hash, showCommitDecorations: showCommitDecorations, - isEvenStripe: stripedRow.isEvenStripe, actions: actions ) .equatable() - .id(stripedRow.row.commit.hash) + .id(row.commit.hash) } if layout.hasMissingParents { @@ -46,29 +45,23 @@ struct GitGraphView: View { .font(.system(size: 10.5)) .foregroundStyle(LitheTheme.tertiaryText) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, graphWidth + 10) + .padding(.leading, maximumGraphWidth + 6) .frame(height: 30) } } } - private var graphWidth: CGFloat { - max(54, CGFloat(max(layout.laneCount, 1)) * 18 + 20) + private func graphWidth(for row: GitGraphRow) -> CGFloat { + max(30, CGFloat(max(row.laneCount, 1)) * 13 + 16) } - private var stripedRows: [StripedRow] { - var stripedRows: [StripedRow] = [] - stripedRows.reserveCapacity(layout.rows.count) - for row in layout.rows { - if let visibleHashes, !visibleHashes.contains(row.commit.hash) { continue } - stripedRows.append(StripedRow(row: row, isEvenStripe: stripedRows.count.isMultiple(of: 2))) - } - return stripedRows + private var maximumGraphWidth: CGFloat { + max(30, CGFloat(max(layout.laneCount, 1)) * 13 + 16) } - private struct StripedRow { - let row: GitGraphRow - let isEvenStripe: Bool + private var visibleRows: [GitGraphRow] { + guard let visibleHashes else { return layout.rows } + return layout.rows.filter { visibleHashes.contains($0.commit.hash) } } } @@ -78,7 +71,6 @@ private struct GitGraphRowView: View, Equatable { let rowHeight: CGFloat let isSelected: Bool let showCommitDecorations: Bool - let isEvenStripe: Bool let actions: GitGraphRowActions @State private var isHovered = false @@ -89,7 +81,6 @@ private struct GitGraphRowView: View, Equatable { && lhs.rowHeight == rhs.rowHeight && lhs.isSelected == rhs.isSelected && lhs.showCommitDecorations == rhs.showCommitDecorations - && lhs.isEvenStripe == rhs.isEvenStripe } var body: some View { @@ -101,31 +92,34 @@ private struct GitGraphRowView: View, Equatable { height: rowHeight ) - HStack(spacing: 5) { - if showCommitDecorations { - ForEach(row.labels) { label in - GitGraphLabelView(label: label) + HStack(spacing: 0) { + if showCommitDecorations, !row.labels.isEmpty { + HStack(spacing: 6) { + ForEach(row.labels) { label in + GitGraphLabelView(label: label) + } } + .padding(.trailing, 4) } Text(row.commit.subject) - .font(.system(size: 13, weight: .regular)) + .font(.system(size: 12.5, weight: .regular)) .foregroundStyle(LitheTheme.primaryText) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) Text(row.commit.authorName) - .font(.system(size: 12)) + .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) .lineLimit(1) .frame(width: 104, alignment: .leading) Text(row.commit.date) - .font(.system(size: 12, design: .monospaced)) + .font(.system(size: 11.5, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 122, alignment: .trailing) + .frame(width: 118, alignment: .trailing) } - .padding(.horizontal, 7) + .padding(.trailing, 8) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: rowHeight) .background(backgroundColor) @@ -152,7 +146,7 @@ private struct GitGraphRowView: View, Equatable { private var backgroundColor: Color { if isSelected { return LitheTheme.selection } if isHovered { return LitheTheme.hoverBackground } - return isEvenStripe ? Color.white.opacity(0.012) : .clear + return .clear } } @@ -160,20 +154,22 @@ private struct GitGraphLabelView: View { let label: GitGraphLabel var body: some View { - Text(label.title) - .font(.system(size: 9.5, weight: .semibold)) - .foregroundStyle(foregroundColor) - .padding(.horizontal, 5) - .frame(height: 18) - .background(backgroundColor) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(foregroundColor.opacity(0.42), lineWidth: 0.6) - } + HStack(spacing: 2) { + GitReferenceTagIcon(color: accentColor) + .frame(width: 12, height: 12) + Text(label.title) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(LitheTheme.primaryText.opacity(0.9)) + .lineLimit(1) + } + .padding(.leading, 3) + .padding(.trailing, 4) + .frame(height: 17) + .background(LitheTheme.primaryText.opacity(0.055)) + .clipShape(RoundedRectangle(cornerRadius: 3)) } - private var foregroundColor: Color { + private var accentColor: Color { switch label.kind { case .head: return LitheTheme.accent case .branch: return LitheTheme.success @@ -181,9 +177,34 @@ private struct GitGraphLabelView: View { case .tag: return LitheTheme.warning } } +} - private var backgroundColor: Color { - foregroundColor.opacity(0.16) +private struct GitReferenceTagIcon: View { + let color: Color + + var body: some View { + Canvas { context, size in + let scale = min(size.width, size.height) / 6.25 + let origin = CGPoint( + x: (size.width - 5.25 * scale) / 2, + y: (size.height - 5 * scale) / 2 + ) + var path = Path() + path.move(to: CGPoint(x: origin.x, y: origin.y)) + path.addLine(to: CGPoint(x: origin.x + 2 * scale, y: origin.y)) + path.addLine(to: CGPoint(x: origin.x + 5 * scale, y: origin.y + 3 * scale)) + path.addLine(to: CGPoint(x: origin.x + 3 * scale, y: origin.y + 5 * scale)) + path.addLine(to: CGPoint(x: origin.x, y: origin.y + 2 * scale)) + path.closeSubpath() + path.addEllipse(in: CGRect( + x: origin.x + scale, + y: origin.y + scale, + width: scale, + height: scale + )) + context.fill(path, with: .color(color), style: FillStyle(eoFill: true)) + } + .accessibilityHidden(true) } } @@ -192,9 +213,9 @@ private struct GitGraphCanvas: View { let width: CGFloat let height: CGFloat - private let laneSpacing: CGFloat = 18 - private let laneLineWidth: CGFloat = 1.8 - private let leftPadding: CGFloat = 10 + private let laneSpacing: CGFloat = 13 + private let laneLineWidth: CGFloat = 1.6 + private let leftPadding: CGFloat = 8 var body: some View { Canvas { context, size in @@ -244,12 +265,12 @@ private struct GitGraphCanvas: View { context.stroke( path, with: .color(color.opacity(0.65)), - style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [2, 2]) + style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [3, 2]) ) } } - let nodeSize: CGFloat = row.isMerge ? 11 : 9 + let nodeSize: CGFloat = row.isMerge ? 9.5 : 8.5 let nodeRect = CGRect( x: currentX - nodeSize / 2, y: centerY - nodeSize / 2, diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 3ca83ef35..ff7589604 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -9,8 +9,8 @@ struct GitLogView: View { @State private var tagsExpanded = true @State private var collapsedReferenceGroups: Set = [] @State private var collapsedFileGroups: Set = [] - @State private var referencePaneWidth: CGFloat = 300 - @State private var referencePaneDragStart: CGFloat = 300 + @State private var referencePaneWidth: CGFloat = 260 + @State private var referencePaneDragStart: CGFloat = 260 @State private var detailPaneWidth: CGFloat = 350 @State private var detailPaneDragStart: CGFloat = 350 @State private var filesPaneHeight: CGFloat? @@ -21,6 +21,11 @@ struct GitLogView: View { @State private var pendingBranchOperation: GitBranchOperationRequest? @State private var comparisonSourceReference: GitReference? @State private var showCommitDecorations = true + @State private var selectedGitLogAuthor: GitLogAuthorSelection? + @State private var selectedGitLogDatePreset = GitLogDatePreset.anyTime + @State private var gitLogPathFilter = "" + @State private var gitLogPathDraft = "" + @State private var showsGitLogPathPopover = false @State private var graphLayout = GitGraphLayout( rows: [], laneCount: 0, @@ -40,8 +45,8 @@ struct GitLogView: View { static let meta = Font.system(size: 12, weight: .regular) static let monoMeta = Font.system(size: 12, weight: .regular, design: .monospaced) static let rowHeight: CGFloat = 38 - static let treeRowHeight: CGFloat = 28 - static let toolbarHeight: CGFloat = 38 + static let treeRowHeight: CGFloat = 24 + static let toolbarHeight: CGFloat = 36 } var body: some View { @@ -145,7 +150,13 @@ struct GitLogView: View { } catch { return } - await model.applyGitLogFilter(model.gitLogSearchQuery) + await model.applyGitLogFilter(gitLogQuery) + } + .onChange(of: model.gitRepositoryRoot) { _ in + selectedGitLogAuthor = nil + selectedGitLogDatePreset = .anyTime + gitLogPathFilter = "" + gitLogPathDraft = "" } .sheet(item: $branchDialogRequest) { request in GitBranchNameDialog(request: request) { name, checkout in @@ -682,11 +693,7 @@ struct GitLogView: View { .stroke(LitheTheme.inputBorder, lineWidth: 1) } - Text("Branch: \(model.selectedGitReference?.shortName ?? "All")") - .font(GitVisual.toolbar) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - .padding(.leading, 2) + gitLogFilterBar Spacer() @@ -946,14 +953,250 @@ struct GitLogView: View { } private var visibleCommitHashes: Set? { - let query = model.gitLogSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { return nil } + guard !gitLogQuery.isEmpty else { return nil } return model.gitLogMatchedCommitHashes } - private var gitLogFilterTaskIdentity: String { - let commits = model.gitCommits.map(\.hash).joined(separator: ",") - return "\(model.gitLogSearchQuery)|\(commits)" + private var gitLogFilterTaskIdentity: GitLogFilterTaskIdentity { + GitLogFilterTaskIdentity( + searchQuery: model.gitLogSearchQuery, + author: selectedGitLogAuthor, + datePreset: selectedGitLogDatePreset, + path: gitLogPathFilter, + commitHashes: model.gitCommits.map(\.hash) + ) + } + + private var gitLogQuery: GitLogQuery { + let path = gitLogPathFilter.trimmingCharacters(in: .whitespacesAndNewlines) + let query = GitLogQuery.parse(model.gitLogSearchQuery).addingStructuredFilters( + currentUserOnly: selectedGitLogAuthor == .currentUser, + exactAuthor: selectedGitLogAuthor?.exactAuthor, + paths: path.isEmpty ? [] : [path] + ) + return selectedGitLogDatePreset.applying(to: query, now: Date()) + } + + private var gitLogAuthorOptions: [GitLogAuthorOption] { + var authorsByID: [String: GitLogAuthorOption] = [:] + for commit in model.gitCommits { + let id = "\(commit.authorName.lowercased())|\(commit.authorEmail.lowercased())" + authorsByID[id] = GitLogAuthorOption( + id: id, + name: commit.authorName, + email: commit.authorEmail + ) + } + return authorsByID.values.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + private var gitLogFilterBar: some View { + HStack(spacing: 8) { + HStack(spacing: 2) { + Menu { + Button { + Task { await model.selectGitReference(nil) } + } label: { + gitLogMenuItem("All Branches", selected: model.selectedGitReference == nil) + } + Divider() + ForEach(model.gitReferences) { reference in + Button { + Task { await model.selectGitReference(reference) } + } label: { + gitLogMenuItem( + reference.shortName, + selected: model.selectedGitReference?.id == reference.id, + systemImage: referenceIcon(reference) + ) + } + } + } label: { + gitLogFilterLabel( + title: "Branch", + selection: model.selectedGitReference?.shortName + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .lithePointer() + + if model.selectedGitReference != nil { + gitLogFilterClearButton(help: "Clear branch filter") { + Task { await model.selectGitReference(nil) } + } + } + } + + HStack(spacing: 2) { + Menu { + Button { + selectedGitLogAuthor = nil + } label: { + gitLogMenuItem("All Users", selected: selectedGitLogAuthor == nil) + } + Button { + selectedGitLogAuthor = .currentUser + } label: { + gitLogMenuItem("Me", selected: selectedGitLogAuthor == .currentUser) + } + if !gitLogAuthorOptions.isEmpty { Divider() } + ForEach(gitLogAuthorOptions) { author in + Button { + selectedGitLogAuthor = .author(name: author.name, email: author.email) + } label: { + gitLogMenuItem( + author.name, + selected: selectedGitLogAuthor == .author(name: author.name, email: author.email) + ) + } + } + } label: { + gitLogFilterLabel(title: "User", selection: selectedGitLogAuthor?.displayName) + } + .menuStyle(.borderlessButton) + .fixedSize() + .lithePointer() + + if selectedGitLogAuthor != nil { + gitLogFilterClearButton(help: "Clear user filter") { + selectedGitLogAuthor = nil + } + } + } + + HStack(spacing: 2) { + Menu { + ForEach(GitLogDatePreset.allCases) { preset in + Button { + selectedGitLogDatePreset = preset + } label: { + gitLogMenuItem( + preset.menuTitle, + selected: selectedGitLogDatePreset == preset + ) + } + } + } label: { + gitLogFilterLabel(title: "Date", selection: selectedGitLogDatePreset.filterTitle) + } + .menuStyle(.borderlessButton) + .fixedSize() + .lithePointer() + + if selectedGitLogDatePreset != .anyTime { + gitLogFilterClearButton(help: "Clear date filter") { + selectedGitLogDatePreset = .anyTime + } + } + } + + HStack(spacing: 2) { + Button { + gitLogPathDraft = gitLogPathFilter + showsGitLogPathPopover = true + } label: { + gitLogFilterLabel( + title: "Path", + selection: gitLogPathFilter.isEmpty ? nil : gitLogPathFilter + ) + } + .buttonStyle(.plain) + .lithePointer() + .popover(isPresented: $showsGitLogPathPopover, arrowEdge: .bottom) { + gitLogPathPopover + } + + if !gitLogPathFilter.isEmpty { + gitLogFilterClearButton(help: "Clear path filter") { + gitLogPathFilter = "" + gitLogPathDraft = "" + } + } + } + } + .lineLimit(1) + } + + private var gitLogPathPopover: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Filter by changed path") + .font(GitVisual.bodyMedium) + .foregroundStyle(LitheTheme.primaryText) + TextField("Directory or file name", text: $gitLogPathDraft) + .textFieldStyle(.roundedBorder) + .font(GitVisual.body) + .onSubmit { applyGitLogPathFilter() } + HStack(spacing: 8) { + Button("Clear") { + gitLogPathDraft = "" + gitLogPathFilter = "" + showsGitLogPathPopover = false + } + Spacer() + Button("Cancel") { + showsGitLogPathPopover = false + } + Button("Apply") { + applyGitLogPathFilter() + } + .keyboardShortcut(.defaultAction) + } + .controlSize(.small) + } + .padding(12) + .frame(width: 300) + } + + private func gitLogFilterLabel(title: String, selection: String?) -> some View { + HStack(spacing: 3) { + Text(selection.map { "\(title): \($0)" } ?? title) + .font(GitVisual.toolbar) + .foregroundStyle(LitheTheme.secondaryText) + if selection == nil { + Image(systemName: "chevron.down") + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(LitheTheme.tertiaryText) + } + } + .frame(height: 22) + .contentShape(Rectangle()) + } + + private func gitLogFilterClearButton(help: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(LitheTheme.tertiaryText) + .frame(width: 14, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .help(LocalizedStringKey(help)) + } + + private func gitLogMenuItem( + _ title: String, + selected: Bool, + systemImage: String? = nil + ) -> some View { + HStack { + if let systemImage { + Image(systemName: systemImage) + } + Text(title) + Spacer() + if selected { Image(systemName: "checkmark") } + } + } + + private func applyGitLogPathFilter() { + gitLogPathFilter = gitLogPathDraft.trimmingCharacters(in: .whitespacesAndNewlines) + gitLogPathDraft = gitLogPathFilter + showsGitLogPathPopover = false } private var commitFileTree: GitCommitFileTreeNode { @@ -1125,6 +1368,91 @@ struct GitLogView: View { } } +private enum GitLogAuthorSelection: Hashable { + case currentUser + case author(name: String, email: String) + + var displayName: String { + switch self { + case .currentUser: + return "Me" + case .author(let name, _): + return name + } + } + + var exactAuthor: GitIdentity? { + switch self { + case .currentUser: + return nil + case .author(let name, let email): + return GitIdentity(name: name, email: email) + } + } +} + +private struct GitLogFilterTaskIdentity: Hashable { + let searchQuery: String + let author: GitLogAuthorSelection? + let datePreset: GitLogDatePreset + let path: String + let commitHashes: [String] +} + +private struct GitLogAuthorOption: Identifiable { + let id: String + let name: String + let email: String +} + +enum GitLogDatePreset: String, CaseIterable, Identifiable, Hashable { + case anyTime + case today + case yesterday + case lastSevenDays + case lastThirtyDays + + var id: String { rawValue } + + var menuTitle: String { + switch self { + case .anyTime: return "Any Time" + case .today: return "Today" + case .yesterday: return "Yesterday" + case .lastSevenDays: return "Last 7 Days" + case .lastThirtyDays: return "Last 30 Days" + } + } + + var filterTitle: String? { + self == .anyTime ? nil : menuTitle + } + + func applying(to query: GitLogQuery, now: Date) -> GitLogQuery { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let today = calendar.startOfDay(for: now) + switch self { + case .anyTime: + return query + case .today: + guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) else { return query } + return query.addingStructuredFilters(afterDate: today, beforeDate: tomorrow) + case .yesterday: + guard let yesterday = calendar.date(byAdding: .day, value: -1, to: today) else { return query } + return query.addingStructuredFilters(afterDate: yesterday, beforeDate: today) + case .lastSevenDays: + guard let firstDay = calendar.date(byAdding: .day, value: -6, to: today), + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) else { return query } + return query.addingStructuredFilters(afterDate: firstDay, beforeDate: tomorrow) + case .lastThirtyDays: + guard let firstDay = calendar.date(byAdding: .day, value: -29, to: today), + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) else { return query } + return query.addingStructuredFilters(afterDate: firstDay, beforeDate: tomorrow) + } + } +} + private struct GitReferenceTreeNode: Identifiable { let path: String let name: String diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 14a60faa4..196f94717 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1172,7 +1172,10 @@ package final class GitFeatureModel: ObservableObject { } package func applyGitLogFilter(_ rawQuery: String) async { - let query = GitLogQuery.parse(rawQuery) + await applyGitLogFilter(GitLogQuery.parse(rawQuery)) + } + + package func applyGitLogFilter(_ query: GitLogQuery) async { gitLogFilterGeneration = UUID() let generation = gitLogFilterGeneration guard !query.isEmpty else { diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 0c8915216..a418f4c4c 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -249,10 +249,40 @@ package struct GitLogQuery: Equatable, Sendable { package let authors: [String] package let branches: [String] package let paths: [String] + package let afterDate: Date? + package let beforeDate: Date? package let currentUserOnly: Bool + package let exactAuthor: GitIdentity? + + package init( + textTerms: [String] = [], + authors: [String] = [], + branches: [String] = [], + paths: [String] = [], + afterDate: Date? = nil, + beforeDate: Date? = nil, + currentUserOnly: Bool = false, + exactAuthor: GitIdentity? = nil + ) { + self.textTerms = textTerms + self.authors = authors + self.branches = branches + self.paths = paths.map { $0.replacingOccurrences(of: "\\", with: "/") } + self.afterDate = afterDate + self.beforeDate = beforeDate + self.currentUserOnly = currentUserOnly + self.exactAuthor = exactAuthor + } package var isEmpty: Bool { - textTerms.isEmpty && authors.isEmpty && branches.isEmpty && paths.isEmpty && !currentUserOnly + textTerms.isEmpty + && authors.isEmpty + && branches.isEmpty + && paths.isEmpty + && afterDate == nil + && beforeDate == nil + && !currentUserOnly + && exactAuthor == nil } package static func parse(_ rawValue: String) -> GitLogQuery { @@ -260,6 +290,8 @@ package struct GitLogQuery: Equatable, Sendable { var authors: [String] = [] var branches: [String] = [] var paths: [String] = [] + var afterDate: Date? + var beforeDate: Date? var currentUserOnly = false for token in tokenize(rawValue) { @@ -277,6 +309,18 @@ package struct GitLogQuery: Equatable, Sendable { case "author": authors.append(value) case "branch": branches.append(value) case "path": paths.append(value.replacingOccurrences(of: "\\", with: "/")) + case "after", "since": + guard let parsedDate = parseBoundaryDate(value) else { + textTerms.append(token) + continue + } + afterDate = afterDate.map { max($0, parsedDate) } ?? parsedDate + case "before", "until": + guard let parsedDate = parseBoundaryDate(value) else { + textTerms.append(token) + continue + } + beforeDate = beforeDate.map { min($0, parsedDate) } ?? parsedDate default: textTerms.append(token) } } @@ -285,11 +329,37 @@ package struct GitLogQuery: Equatable, Sendable { authors: authors, branches: branches, paths: paths, + afterDate: afterDate, + beforeDate: beforeDate, currentUserOnly: currentUserOnly ) } + package func addingStructuredFilters( + currentUserOnly: Bool = false, + exactAuthor: GitIdentity? = nil, + paths: [String] = [], + afterDate: Date? = nil, + beforeDate: Date? = nil + ) -> GitLogQuery { + GitLogQuery( + textTerms: textTerms, + authors: authors, + branches: branches, + paths: self.paths + paths, + afterDate: Self.laterBoundary(afterDate, self.afterDate), + beforeDate: Self.earlierBoundary(beforeDate, self.beforeDate), + currentUserOnly: self.currentUserOnly || currentUserOnly, + exactAuthor: exactAuthor ?? self.exactAuthor + ) + } + package func matchesMetadata(_ commit: GitCommit, identity: GitIdentity?) -> Bool { + if afterDate != nil || beforeDate != nil { + guard let commitDate = Self.parseCommitDate(commit.date) else { return false } + if let afterDate, commitDate < afterDate { return false } + if let beforeDate, commitDate >= beforeDate { return false } + } if currentUserOnly { guard let identity, !identity.isEmpty else { return false } let matchesName = identity.name.map { @@ -300,6 +370,17 @@ package struct GitLogQuery: Equatable, Sendable { } ?? false guard matchesName || matchesEmail else { return false } } + if let exactAuthor { + let matchesExactAuthor: Bool + if let email = exactAuthor.email { + matchesExactAuthor = commit.authorEmail.caseInsensitiveCompare(email) == .orderedSame + } else if let name = exactAuthor.name { + matchesExactAuthor = commit.authorName.caseInsensitiveCompare(name) == .orderedSame + } else { + matchesExactAuthor = false + } + guard matchesExactAuthor else { return false } + } if !authors.isEmpty { guard authors.contains(where: { author in commit.authorName.localizedCaseInsensitiveContains(author) @@ -341,6 +422,53 @@ package struct GitLogQuery: Equatable, Sendable { if !current.isEmpty { tokens.append(current) } return tokens } + + private static func laterBoundary(_ first: Date?, _ second: Date?) -> Date? { + switch (first, second) { + case let (.some(first), .some(second)): return max(first, second) + case let (.some(first), .none): return first + case let (.none, .some(second)): return second + case (.none, .none): return nil + } + } + + private static func earlierBoundary(_ first: Date?, _ second: Date?) -> Date? { + switch (first, second) { + case let (.some(first), .some(second)): return min(first, second) + case let (.some(first), .none): return first + case let (.none, .some(second)): return second + case (.none, .none): return nil + } + } + + private static func parseBoundaryDate(_ value: String) -> Date? { + let components = value.split(separator: "-", omittingEmptySubsequences: false) + guard components.count == 3, + let year = Int(components[0]), + let month = Int(components[1]), + let day = Int(components[2]) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar.date(from: DateComponents(year: year, month: month, day: day)) + } + + private static func parseCommitDate(_ value: String) -> Date? { + let iso8601 = ISO8601DateFormatter() + if let date = iso8601.date(from: value) { return date } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + for format in [ + "EEE MMM d HH:mm:ss yyyy Z", + "yyyy-MM-dd HH:mm:ss Z", + "yyyy/MM/dd HH:mm" + ] { + formatter.dateFormat = format + if let date = formatter.date(from: value) { return date } + } + return nil + } } private extension String { diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 04766a71d..fdecdca3a 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -126,6 +126,100 @@ struct GitModuleTests { )) } + @Test + func gitLogStructuredFiltersPreserveQuotedPathsAndMatchSelectedAuthorExactly() { + let selectedAuthor = GitCommit( + hash: "1111111111111111", + shortHash: "1111111", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "dev@example.com", + date: "2026-08-27T09:30:00+08:00", + subject: "Update quoted path", + decorations: "" + ) + let similarAuthor = GitCommit( + hash: "2222222222222222", + shortHash: "2222222", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "dev@example.com.invalid", + date: "2026-08-27T09:30:00+08:00", + subject: "Update quoted path", + decorations: "" + ) + let path = #"Sources/It's a "quoted" file.swift"# + let query = GitLogQuery.parse("Update").addingStructuredFilters( + exactAuthor: GitIdentity(name: selectedAuthor.authorName, email: selectedAuthor.authorEmail), + paths: [path] + ) + + #expect(query.paths == [path]) + #expect(query.matchesMetadata(selectedAuthor, identity: nil)) + #expect(!query.matchesMetadata(similarAuthor, identity: nil)) + #expect(query.matchesPaths([path])) + #expect(!query.matchesPaths([#"Sources/Its a "quoted" file.swift"#])) + } + + @Test + func gitLogStructuredAuthorFallsBackToExactNameWhenEmailIsBlank() { + let exactName = GitCommit( + hash: "3333333333333333", + shortHash: "3333333", + parentHashes: [], + authorName: "Alice", + authorEmail: "", + date: "2026-08-27T09:30:00+08:00", + subject: "Exact author", + decorations: "" + ) + let similarName = GitCommit( + hash: "4444444444444444", + shortHash: "4444444", + parentHashes: [], + authorName: "Alice Smith", + authorEmail: "", + date: "2026-08-27T09:30:00+08:00", + subject: "Similar author", + decorations: "" + ) + let query = GitLogQuery(exactAuthor: GitIdentity(name: "Alice", email: nil)) + + #expect(query.matchesMetadata(exactName, identity: nil)) + #expect(!query.matchesMetadata(similarName, identity: nil)) + } + + @Test + func gitLogQueryMatchesInclusiveAfterAndExclusiveBeforeDates() { + let insideRange = GitCommit( + hash: "1111111111111111", + shortHash: "1111111", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026-08-16T09:30:00+08:00", + subject: "Inside range", + decorations: "" + ) + let atExclusiveEnd = GitCommit( + hash: "2222222222222222", + shortHash: "2222222", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026-08-18T00:00:00Z", + subject: "Outside range", + decorations: "" + ) + let query = GitLogQuery.parse("after:2026-08-16 before:2026-08-18") + + #expect(!query.isEmpty) + #expect(query.afterDate != nil) + #expect(query.beforeDate != nil) + #expect(query.matchesMetadata(insideRange, identity: nil)) + #expect(!query.matchesMetadata(atExclusiveEnd, identity: nil)) + } + @Test func workingTreeComparisonMergesTrackedAndUntrackedFiles() async { let root = URL(fileURLWithPath: "/workspace") diff --git a/macos/Tests/LitheTests/GitLogDatePresetTests.swift b/macos/Tests/LitheTests/GitLogDatePresetTests.swift new file mode 100644 index 000000000..b3d0982d0 --- /dev/null +++ b/macos/Tests/LitheTests/GitLogDatePresetTests.swift @@ -0,0 +1,37 @@ +import Foundation +@testable import Lithe +@testable import LitheGitModule +import Testing + +struct GitLogDatePresetTests { + @Test + func calendarRangePresetsExcludeFutureCommits() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let now = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 27, + hour: 12 + ))) + let tomorrow = try #require(calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))) + let futureDate = try #require(calendar.date(byAdding: .hour, value: 12, to: tomorrow)) + let futureCommit = GitCommit( + hash: "3333333333333333", + shortHash: "3333333", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: ISO8601DateFormatter().string(from: futureDate), + subject: "Future commit", + decorations: "" + ) + + for preset in [GitLogDatePreset.today, .lastSevenDays, .lastThirtyDays] { + let query = preset.applying(to: GitLogQuery(), now: now) + + #expect(query.beforeDate == tomorrow) + #expect(!query.matchesMetadata(futureCommit, identity: nil)) + } + } +}