From 068748cd6b2aa06253e4736d509a1728a591e800 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 20 Sep 2026 14:38:46 +0700 Subject: [PATCH] refactor: Refactor history table column layout to use persisted proportions --- macgit/Models/HistoryTableColumnLayout.swift | 29 +++++ .../Views/History/BranchGraphRowCanvas.swift | 12 +- .../History/HistoryCommitMessageCell.swift | 3 - .../HistoryTableScrollCoordinator.swift | 116 ++++++++++-------- macgit/Views/History/HistoryView.swift | 44 +++---- macgit/Views/MainWindow/RepoPickerView.swift | 90 ++++++++++---- .../SidebarPointingHandCursorModifier.swift | 4 +- .../HistoryTableColumnLayoutTests.swift | 64 ++++++++++ .../HistoryTableScrollCoordinatorTests.swift | 88 +++++++++++++ 9 files changed, 342 insertions(+), 108 deletions(-) create mode 100644 macgit/Models/HistoryTableColumnLayout.swift create mode 100644 macgitTests/HistoryTableColumnLayoutTests.swift create mode 100644 macgitTests/HistoryTableScrollCoordinatorTests.swift diff --git a/macgit/Models/HistoryTableColumnLayout.swift b/macgit/Models/HistoryTableColumnLayout.swift new file mode 100644 index 0000000..42c4bdb --- /dev/null +++ b/macgit/Models/HistoryTableColumnLayout.swift @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +struct HistoryTableColumnLayout: Codable { + private(set) var widths: [String: Double] + private(set) var viewportWidth: Double + + var isValid: Bool { + viewportWidth.isFinite && viewportWidth > 0 + && ["graph", "message", "author", "date", "commit"].allSatisfy { + guard let width = widths[$0] else { return false } + return width.isFinite && width > 0 + } + } + + func width(for column: String, viewportWidth: Double, minimumWidth: Double) -> Double { + max(minimumWidth, (widths[column] ?? minimumWidth) * viewportWidth / self.viewportWidth) + } + + mutating func resizeColumn(_ column: String, to width: Double, viewportWidth: Double) { + // Rebase all columns, including hidden ones, without baking temporary + // minimum-width constraints into the user's saved proportions. + let scale = viewportWidth / self.viewportWidth + widths = widths.mapValues { $0 * scale } + widths[column] = width + self.viewportWidth = viewportWidth + } +} diff --git a/macgit/Views/History/BranchGraphRowCanvas.swift b/macgit/Views/History/BranchGraphRowCanvas.swift index d9ceaee..99659bb 100644 --- a/macgit/Views/History/BranchGraphRowCanvas.swift +++ b/macgit/Views/History/BranchGraphRowCanvas.swift @@ -27,15 +27,15 @@ struct BranchGraphRowCanvas: View { let rowIndex: Int @Environment(\.backgroundProminence) private var backgroundProminence - private var graphWidth: CGFloat { - CGFloat(model.laneCount) * Self.laneWidth + Self.trailingPadding - } - var body: some View { - Canvas { context, _ in + Canvas { context, size in + // Keep dense graphs inside their column while retaining the native + // lane spacing and the full row height for continuous vertical lines. + context.clip(to: Path(CGRect(origin: .zero, size: size))) drawRow(in: &context) } - .frame(width: graphWidth, height: Self.rowHeight) + .frame(maxWidth: .infinity) + .frame(height: Self.rowHeight) // A small native Table row proposes 16 pt of cell content with 4 pt // vertical insets. Extending the canvas through those insets makes the // graph join exactly at adjacent row boundaries. diff --git a/macgit/Views/History/HistoryCommitMessageCell.swift b/macgit/Views/History/HistoryCommitMessageCell.swift index eddac67..7cf7954 100644 --- a/macgit/Views/History/HistoryCommitMessageCell.swift +++ b/macgit/Views/History/HistoryCommitMessageCell.swift @@ -22,7 +22,6 @@ struct HistoryCommitMessageCell: View { let commit: Commit let graphModel: CommitGraphModel - let rowIndex: Int let isDragActive: Bool let scrollCoordinator: HistoryTableScrollCoordinator let onAppear: () -> Void @@ -34,8 +33,6 @@ struct HistoryCommitMessageCell: View { var body: some View { HStack(spacing: 4) { - BranchGraphRowCanvas(model: graphModel, rowIndex: rowIndex) - if !commit.refs.isEmpty { HStack(spacing: 4) { ForEach(commit.refs.prefix(3), id: \.self) { ref in diff --git a/macgit/Views/History/HistoryTableScrollCoordinator.swift b/macgit/Views/History/HistoryTableScrollCoordinator.swift index 3f106dc..6eec805 100644 --- a/macgit/Views/History/HistoryTableScrollCoordinator.swift +++ b/macgit/Views/History/HistoryTableScrollCoordinator.swift @@ -24,8 +24,9 @@ final class HistoryTableScrollCoordinator { private weak var tableView: NSTableView? private weak var observedClipView: NSClipView? private let defaults: UserDefaults - private let ratiosKey = "history.tableColumnRatios" - private var columnRatios: [String: Double] + private let layoutKey = "history.tableColumnLayout" + private var columnLayout: HistoryTableColumnLayout? + private let initialColumnRatios: [String: Double] private var viewportObservers: [NSObjectProtocol] = [] private var lastViewportWidth: CGFloat = 0 private var lastVisibleColumns: [String] = [] @@ -36,14 +37,22 @@ final class HistoryTableScrollCoordinator { init(defaults: UserDefaults = .standard) { self.defaults = defaults - let initial = ["message": 0.45, "author": 0.25, "date": 0.18, "commit": 0.12] - let saved = defaults.dictionary(forKey: ratiosKey) as? [String: Double] + let initial = ["graph": 0.20, "message": 0.40, "author": 0.18, "date": 0.14, "commit": 0.08] + let saved = defaults.dictionary(forKey: "history.tableColumnRatios") as? [String: Double] let legacy = defaults.dictionary(forKey: "history.tableColumnWidths") as? [String: Double] let valid = (saved ?? legacy ?? initial).filter { initial[$0.key] != nil && $0.value.isFinite && $0.value > 0 } let total = valid.values.reduce(0, +) - columnRatios = initial.merging(valid.mapValues { $0 / max(total, 1e-9) }) { _, saved in saved } + // Older layouts have no Graph column. Reserve its default share and + // preserve the relative proportions of the user's existing columns. + let savedShare = valid["graph"] == nil ? 1 - initial["graph"]! : 1 + initialColumnRatios = initial.merging(valid.mapValues { $0 / max(total, 1e-9) * savedShare }) { _, saved in saved } + if let data = defaults.data(forKey: layoutKey), + let layout = try? JSONDecoder().decode(HistoryTableColumnLayout.self, from: data), + layout.isValid { + columnLayout = layout + } } deinit { @@ -141,61 +150,66 @@ final class HistoryTableScrollCoordinator { clipView.bounds.width > 0 else { return } let keys = visibleColumns.compactMap(Self.columnKey) guard abs(lastViewportWidth - clipView.bounds.width) > 0.01 || keys != lastVisibleColumns else { return } - applyColumnRatios() + applyColumnWidths() } - private func applyColumnRatios() { + private func applyColumnWidths() { + // Scroller tiling can notify viewport changes before AppKit publishes + // the dragged column's new width. Never restore stale widths mid-drag. + guard (tableView?.headerView?.resizedColumn ?? -1) < 0 else { return } let columns = visibleColumns - guard !columns.isEmpty, availableWidth(for: columns) > 0 else { return } - let weights = columns.map { CGFloat(columnRatios[Self.columnKey($0)!] ?? 1) } - var widths = Array(repeating: CGFloat.zero, count: columns.count) - var remaining = max(availableWidth(for: columns), columns.reduce(0) { $0 + $1.minWidth }) - var pending = Array(columns.indices) - // Pin columns that reach their minimum, then redistribute the remaining - // space proportionally. Window resizing never overwrites user ratios. - while !pending.isEmpty { - let totalWeight = pending.reduce(CGFloat.zero) { $0 + weights[$1] } - let constrained = pending.filter { remaining * weights[$0] / totalWeight < columns[$0].minWidth } - if constrained.isEmpty { - for index in pending { - widths[index] = remaining * weights[index] / totalWeight - } - break - } - for index in constrained { - widths[index] = columns[index].minWidth - remaining -= widths[index] - } - pending.removeAll { constrained.contains($0) } + guard !columns.isEmpty, + let viewportWidth = tableView?.enclosingScrollView?.contentView.bounds.width, + viewportWidth > 0, availableWidth(for: columns) > 0 else { return } + if columnLayout == nil { + // Convert old proportions once, using the first available viewport. + // Keep this reference unchanged during subsequent window resizing. + columnLayout = HistoryTableColumnLayout( + widths: initialColumnRatios.mapValues { $0 * Double(availableWidth(for: columns)) }, + viewportWidth: Double(viewportWidth) + ) + saveColumnLayout() + } + guard let columnLayout else { return } + let widths = columns.map { column in + CGFloat(columnLayout.width( + for: Self.columnKey(column)!, + viewportWidth: Double(viewportWidth), + minimumWidth: Double(column.minWidth) + )) } setWidths(widths, for: columns) } private func captureColumnResize(in tableView: NSTableView, index: Int) { - guard tableView.tableColumns.indices.contains(index) else { return } + guard tableView.tableColumns.indices.contains(index), + let viewportWidth = tableView.enclosingScrollView?.contentView.bounds.width, + viewportWidth > 0 else { return } let columns = visibleColumns - guard let active = columns.firstIndex(where: { $0 === tableView.tableColumns[index] }) else { return } + guard let active = columns.firstIndex(where: { $0 === tableView.tableColumns[index] }), + columnLayout != nil else { return } var widths = columns.map { appliedWidths[Self.columnKey($0)!] ?? $0.width } widths[active] = max(columns[active].minWidth, columns[active].width) - let target = max(availableWidth(for: columns), columns.reduce(0) { $0 + $1.minWidth }) - var excess = widths.reduce(0, +) - target - // Prefer the next visible column, then the nearest remaining neighbors. - let neighbors = Array(columns.indices.dropFirst(active + 1)) + Array(columns.indices.prefix(active).reversed()) - for neighbor in neighbors { - let adjustment = max(columns[neighbor].minWidth - widths[neighbor], -excess) - widths[neighbor] += adjustment - excess += adjustment - if abs(excess) < 0.01 { break } - } - widths[active] = max(columns[active].minWidth, widths[active] - excess) - setWidths(widths, for: columns) - let total = widths.reduce(0, +) - guard total > 0 else { return } - let visibleWeight = columns.reduce(0.0) { $0 + (columnRatios[Self.columnKey($1)!] ?? 0) } + columnLayout?.resizeColumn( + Self.columnKey(columns[active])!, + to: Double(widths[active]), + viewportWidth: Double(viewportWidth) + ) + // AppKit owns layout during header tracking. Retiling from inside its + // resize notification re-enters scroller layout at the overflow boundary. + // Record the new width without writing column widths back to AppKit. for (column, width) in zip(columns, widths) { - columnRatios[Self.columnKey(column)!] = Double(width / total) * max(visibleWeight, 1e-9) + appliedWidths[Self.columnKey(column)!] = width } - defaults.set(columnRatios, forKey: ratiosKey) + lastViewportWidth = viewportWidth + lastVisibleColumns = columns.compactMap(Self.columnKey) + saveColumnLayout() + } + + private func saveColumnLayout() { + guard let columnLayout, + let data = try? JSONEncoder().encode(columnLayout) else { return } + defaults.set(data, forKey: layoutKey) } private func setWidths(_ widths: [CGFloat], for columns: [NSTableColumn]) { @@ -203,7 +217,11 @@ final class HistoryTableScrollCoordinator { isRestoringWidths = true defer { isRestoringWidths = false } tableView.columnAutoresizingStyle = .noColumnAutoresizing + tableView.enclosingScrollView?.hasHorizontalScroller = true for (column, width) in zip(columns, widths) { + // The coordinator handles window scaling. Native size-to-fit must + // not shrink columns when the horizontal scroller first appears. + column.resizingMask = .userResizingMask if abs(column.width - width) > 0.01 { column.width = width } @@ -224,7 +242,7 @@ final class HistoryTableScrollCoordinator { guard tableView.window != nil else { continue } tableView.layoutSubtreeIfNeeded() self.observeViewport(of: tableView) - self.applyColumnRatios() + self.applyColumnWidths() } } } @@ -233,7 +251,7 @@ final class HistoryTableScrollCoordinator { // SwiftUI's native identifiers are fresh UUIDs on every mount. Header // titles remain stable even when the user reorders or hides columns. let key = column.title.lowercased() - return ["message", "author", "date", "commit"].contains(key) ? key : nil + return ["graph", "message", "author", "date", "commit"].contains(key) ? key : nil } func scrollToRowWhenReady(_ row: Int) async { diff --git a/macgit/Views/History/HistoryView.swift b/macgit/Views/History/HistoryView.swift index d17d631..8a06ebb 100644 --- a/macgit/Views/History/HistoryView.swift +++ b/macgit/Views/History/HistoryView.swift @@ -535,22 +535,29 @@ struct HistoryView: View { let rowIndexByHash = Dictionary( uniqueKeysWithValues: commits.enumerated().map { ($0.element.hash, $0.offset) } ) - GeometryReader { proxy in - let tableWidths = Self.tableColumnWidths( - for: proxy.size.width - ) - - ZStack(alignment: .bottom) { + // Fixed initial hints only; the native coordinator owns all + // subsequent sizing, including window and scroller changes. + ZStack(alignment: .bottom) { Table( of: Commit.self, selection: $tableSelection, columnCustomization: $tableColumnCustomization ) { + TableColumn("Graph") { commit in + BranchGraphRowCanvas( + model: graphModel, + rowIndex: rowIndexByHash[commit.hash] ?? 0 + ) + .opacity(activeDragCommitHashes.contains(commit.hash) ? 0.4 : 1) + } + .width(min: 60, ideal: 200, max: .infinity) + .customizationID("graph") + .disabledCustomizationBehavior([.reorder, .visibility]) + TableColumn("Message") { commit in HistoryCommitMessageCell( commit: commit, graphModel: graphModel, - rowIndex: rowIndexByHash[commit.hash] ?? 0, isDragActive: activeDragCommitHashes.contains(commit.hash), scrollCoordinator: tableScrollCoordinator, onAppear: { @@ -560,7 +567,7 @@ struct HistoryView: View { } .width( min: 120, - ideal: tableWidths.message, + ideal: 400, max: .infinity ) .customizationID("message") @@ -575,7 +582,7 @@ struct HistoryView: View { } .width( min: 140, - ideal: tableWidths.author, + ideal: 180, max: .infinity ) .customizationID("author") @@ -597,7 +604,7 @@ struct HistoryView: View { } .width( min: 100, - ideal: tableWidths.date, + ideal: 140, max: .infinity ) .alignment(.leading) @@ -612,7 +619,7 @@ struct HistoryView: View { } .width( min: 72, - ideal: tableWidths.commit, + ideal: 80, max: .infinity ) .alignment(.leading) @@ -657,27 +664,12 @@ struct HistoryView: View { .background(.regularMaterial, in: Capsule()) .padding(.bottom, 8) } - } } } } .id(historyLoadKey) } - // Initial layout preferences only. The native table coordinator restores - // saved proportions after SwiftUI configures the columns and on viewport resize. - private static func tableColumnWidths( - for availableWidth: CGFloat - ) -> (message: CGFloat, author: CGFloat, date: CGFloat, commit: CGFloat) { - let width = max(1, availableWidth - 1) - return ( - message: width * 0.45, - author: width * 0.25, - date: width * 0.18, - commit: width * 0.12 - ) - } - // MARK: - Bottom Panel private var commitDetailPanel: some View { diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index 92d0b05..e218b56 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -77,6 +77,8 @@ struct RepoPickerView: View { @State private var rowStates: [URL: RepoPickerRowState] = [:] @State private var missingRepository: RecentRepository? @State private var showingMissingRepositoryAlert = false + @State private var hoveredRepositoryURL: URL? + @State private var hoveredBookmarkURL: URL? let title: String let showsApplicationIcon: Bool @@ -290,29 +292,45 @@ struct RepoPickerView: View { .foregroundStyle(.blue) } .padding(.bottom, 8) - HStack(spacing: 12) { - Button(action: openExistingRepository) { - Label("Open", systemImage: "folder") - .frame(maxWidth: .infinity) + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + dashboardActionButtons } - .buttonStyle(.borderedProminent) - .help("Open Repository") - Button { showingCloneSheet = true } label: { - Label("Clone", systemImage: "arrow.down.circle") - .frame(maxWidth: .infinity) + VStack(spacing: 6) { + dashboardActionButtons } - .buttonStyle(.bordered) - .help("Clone Repository") - } - Button(action: createRepository) { - Label("Create Repository", systemImage: "plus.rectangle.on.folder") - .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.bordered) } .controlSize(.large) } + @ViewBuilder + private var dashboardActionButtons: some View { + Button(action: openExistingRepository) { + Label("Open", systemImage: "folder") + .fixedSize() + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .sidebarPointingHandCursor() + .help("Open Repository") + Button { showingCloneSheet = true } label: { + Label("Clone", systemImage: "arrow.down.circle") + .fixedSize() + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .sidebarPointingHandCursor() + .help("Clone Repository") + Button(action: createRepository) { + Label("Create Repository", systemImage: "plus.rectangle.on.folder") + .fixedSize() + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .sidebarPointingHandCursor() + } + private func createRepository() { let panel = NSSavePanel() panel.title = "Create Repository" @@ -366,6 +384,7 @@ struct RepoPickerView: View { .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) + .sidebarPointingHandCursor() .controlSize(.large) .frame(width: 200) @@ -375,6 +394,7 @@ struct RepoPickerView: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) + .sidebarPointingHandCursor() .controlSize(.large) .frame(width: 200) } @@ -395,6 +415,7 @@ struct RepoPickerView: View { .frame(width: 28, height: 28) } .buttonStyle(.borderless) + .sidebarPointingHandCursor() .help(showBookmarkedOnly ? "Show all repositories" : "Show bookmarked repositories only") .accessibilityLabel(showBookmarkedOnly ? "Show all repositories" : "Show bookmarked repositories only") @@ -433,6 +454,7 @@ struct RepoPickerView: View { .frame(width: 28, height: 28) } .menuStyle(.borderlessButton) + .sidebarPointingHandCursor() .disabled(store.repositories.isEmpty && bookmarkController.bookmarks.isEmpty) } } @@ -502,9 +524,17 @@ struct RepoPickerView: View { repoRowContent(repo) } .buttonStyle(.plain) + .sidebarPointingHandCursor() bookmarkButton(for: repo) } + .background( + Color.primary.opacity(hoveredRepositoryURL == repo.url ? 0.06 : 0), + in: RoundedRectangle(cornerRadius: 8) + ) + .onHover { isHovered in + hoveredRepositoryURL = isHovered ? repo.url : nil + } .task(id: repo.url) { await loadRowPresentation(for: repo) } @@ -533,16 +563,20 @@ struct RepoPickerView: View { } } } label: { - if let bookmarkID, - bookmarkController.syncingBookmarkIDs.contains(bookmarkID) { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: bookmarkID == nil ? "star" : "star.fill") + Group { + if let bookmarkID, + bookmarkController.syncingBookmarkIDs.contains(bookmarkID) { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: bookmarkID == nil ? "star" : "star.fill") + } } + .frame(width: 28, height: 28) + .contentShape(Rectangle()) } .buttonStyle(.borderless) - .frame(width: 28, height: 28) + .sidebarPointingHandCursor() .help(bookmarkID == nil ? "Bookmark repository" : "Remove bookmark") .accessibilityLabel(bookmarkID == nil ? "Bookmark repository" : "Remove bookmark") } @@ -575,11 +609,13 @@ struct RepoPickerView: View { bookmarkToClone = bookmark } .buttonStyle(.borderedProminent) + .sidebarPointingHandCursor() Button(isDashboardSidebar ? "Link" : "Link Folder") { chooseFolderToLink(bookmark) } .buttonStyle(.bordered) + .sidebarPointingHandCursor() Button("Remove bookmark", systemImage: "star.fill") { Task { @@ -588,10 +624,18 @@ struct RepoPickerView: View { } .labelStyle(.iconOnly) .buttonStyle(.borderless) + .sidebarPointingHandCursor() .help("Remove bookmark") } .padding(.vertical, 12) .padding(.horizontal, 4) + .background( + Color.primary.opacity(hoveredBookmarkURL == bookmark.remoteURL ? 0.06 : 0), + in: RoundedRectangle(cornerRadius: 8) + ) + .onHover { isHovered in + hoveredBookmarkURL = isHovered ? bookmark.remoteURL : nil + } } private func openRecentRepository(_ repo: RecentRepository) { diff --git a/macgit/Views/MainWindow/Sidebar/SidebarPointingHandCursorModifier.swift b/macgit/Views/MainWindow/Sidebar/SidebarPointingHandCursorModifier.swift index b29a3bc..67763cc 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarPointingHandCursorModifier.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarPointingHandCursorModifier.swift @@ -20,12 +20,14 @@ import AppKit import SwiftUI struct SidebarPointingHandCursorModifier: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + func body(content: Content) -> some View { content .onContinuousHover { phase in switch phase { case .active: - NSCursor.pointingHand.set() + (isEnabled ? NSCursor.pointingHand : NSCursor.arrow).set() case .ended: NSCursor.arrow.set() } diff --git a/macgitTests/HistoryTableColumnLayoutTests.swift b/macgitTests/HistoryTableColumnLayoutTests.swift new file mode 100644 index 0000000..cc794f1 --- /dev/null +++ b/macgitTests/HistoryTableColumnLayoutTests.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +@MainActor +final class HistoryTableColumnLayoutTests: XCTestCase { + private func makeLayout() -> HistoryTableColumnLayout { + HistoryTableColumnLayout( + widths: ["graph": 200, "message": 400, "author": 180, "date": 140, "commit": 80], + viewportWidth: 1_000 + ) + } + + func testDraggingPastViewportPreservesOtherColumnsAndOverflow() async { + var layout = makeLayout() + layout.resizeColumn("graph", to: 600, viewportWidth: 1_000) + + XCTAssertEqual(layout.widths["message"], 400) + XCTAssertEqual(layout.widths.values.reduce(0, +), 1_400) + XCTAssertEqual(layout.width(for: "graph", viewportWidth: 1_200, minimumWidth: 60), 720) + XCTAssertEqual(layout.width(for: "message", viewportWidth: 1_200, minimumWidth: 120), 480) + } + + func testMinimumWidthDoesNotAlterSavedLayoutWhenWindowGrowsAgain() async { + let layout = makeLayout() + XCTAssertEqual(layout.width(for: "author", viewportWidth: 500, minimumWidth: 140), 140) + XCTAssertEqual(layout.width(for: "author", viewportWidth: 1_000, minimumWidth: 140), 180) + XCTAssertEqual(layout.viewportWidth, 1_000) + } + + func testDraggingAfterWindowResizeRebasesHiddenAndMinimumConstrainedColumns() async { + var layout = makeLayout() + layout.resizeColumn("graph", to: 300, viewportWidth: 500) + + XCTAssertEqual(layout.viewportWidth, 500) + XCTAssertEqual(layout.widths["message"], 200) + XCTAssertEqual(layout.widths["author"], 90) + XCTAssertEqual(layout.width(for: "author", viewportWidth: 1_000, minimumWidth: 140), 180) + XCTAssertEqual(layout.width(for: "graph", viewportWidth: 1_000, minimumWidth: 60), 600) + } + + func testSavedLayoutRoundTripPreservesOverflowAndReferenceViewport() async throws { + var layout = makeLayout() + layout.resizeColumn("graph", to: 800, viewportWidth: 1_000) + let restored = try JSONDecoder().decode( + HistoryTableColumnLayout.self, + from: JSONEncoder().encode(layout) + ) + + XCTAssertTrue(restored.isValid) + XCTAssertEqual(restored.widths, layout.widths) + XCTAssertEqual(restored.viewportWidth, 1_000) + XCTAssertEqual(restored.width(for: "graph", viewportWidth: 1_500, minimumWidth: 60), 1_200) + } + + func testInvalidPersistedLayoutIsRejected() async { + XCTAssertFalse(HistoryTableColumnLayout(widths: makeLayout().widths, viewportWidth: 0).isValid) + XCTAssertFalse(HistoryTableColumnLayout(widths: ["graph": 200], viewportWidth: 1_000).isValid) + var widths = makeLayout().widths + widths["graph"] = .infinity + XCTAssertFalse(HistoryTableColumnLayout(widths: widths, viewportWidth: 1_000).isValid) + } +} diff --git a/macgitTests/HistoryTableScrollCoordinatorTests.swift b/macgitTests/HistoryTableScrollCoordinatorTests.swift new file mode 100644 index 0000000..e33e383 --- /dev/null +++ b/macgitTests/HistoryTableScrollCoordinatorTests.swift @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import XCTest +@testable import macgit + +@MainActor +final class HistoryTableScrollCoordinatorTests: XCTestCase { + func testViewportChangeDuringHeaderDragDoesNotRestoreOldWidths() async { + let fixture = makeFixture() + let graph = fixture.table.tableColumns[0] + let originalWidth = graph.width + fixture.header.testResizedColumn = 0 + graph.width = originalWidth + 1 + + // A scroller transition can change the clip frame before the column's + // resize notification arrives. The persisted width is still the old one. + let clipView = fixture.scrollView.contentView + clipView.setFrameSize(NSSize(width: clipView.bounds.width - 15, height: 280)) + NotificationCenter.default.post(name: NSView.boundsDidChangeNotification, object: clipView) + + XCTAssertEqual(graph.width, originalWidth + 1, accuracy: 0.01) + XCTAssertTrue(fixture.table.tableColumns.allSatisfy { $0.resizingMask == .userResizingMask }) + withExtendedLifetime(fixture.coordinator) {} + } + + func testResizeNotificationRecordsOverflowWithoutRetiling() async throws { + let fixture = makeFixture() + let graph = fixture.table.tableColumns[0] + let messageWidth = fixture.table.tableColumns[1].width + fixture.header.testResizedColumn = 0 + graph.width += 1 + let tileCount = fixture.table.tileCount + + NotificationCenter.default.post(name: NSTableView.columnDidResizeNotification, object: fixture.table) + + XCTAssertEqual(fixture.table.tileCount, tileCount) + XCTAssertEqual(fixture.table.tableColumns[1].width, messageWidth) + let data = try XCTUnwrap(fixture.defaults.data(forKey: "history.tableColumnLayout")) + let saved = try JSONDecoder().decode(HistoryTableColumnLayout.self, from: data) + XCTAssertEqual(try XCTUnwrap(saved.widths["graph"]), Double(graph.width), accuracy: 0.01) + withExtendedLifetime(fixture.coordinator) {} + } + + private func makeFixture() -> ( + coordinator: HistoryTableScrollCoordinator, + scrollView: NSScrollView, + table: LayoutCountingHistoryTable, + header: ResizingHistoryHeader, + defaults: UserDefaults + ) { + let suiteName = "HistoryTableScrollCoordinatorTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 1_000, height: 300)) + let table = LayoutCountingHistoryTable(frame: scrollView.bounds) + let header = ResizingHistoryHeader() + table.headerView = header + for title in ["Graph", "Message", "Author", "Date", "Commit"] { + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(title)) + column.title = title + column.minWidth = 50 + column.maxWidth = 10_000 + table.addTableColumn(column) + } + scrollView.documentView = table + let marker = NSView() + table.addSubview(marker) + let coordinator = HistoryTableScrollCoordinator(defaults: defaults) + XCTAssertTrue(coordinator.attach(from: marker)) + return (coordinator, scrollView, table, header, defaults) + } +} + +@MainActor +private final class ResizingHistoryHeader: NSTableHeaderView { + var testResizedColumn = -1 + override var resizedColumn: Int { testResizedColumn } +} + +@MainActor +private final class LayoutCountingHistoryTable: NSTableView { + var tileCount = 0 + override func tile() { + tileCount += 1 + super.tile() + } +}