diff --git a/macgit/App/AppState.swift b/macgit/App/AppState.swift index c528ad2..f7e02df 100644 --- a/macgit/App/AppState.swift +++ b/macgit/App/AppState.swift @@ -45,6 +45,7 @@ final class AppState: ObservableObject { private static let historyIncludeRemotesKey = "historyIncludeRemotes" private static let autoFetchEnabledKey = "autoFetchEnabled" private static let refreshOnAppActiveKey = "refreshOnAppActive" + private static let fileListModeKey = "fileListMode" private static let fitRepositoryWindowsToScreenKey = "fitRepositoryWindowsToScreen" private static let settingsSyncEnabledKey = "settingsSyncEnabled" private static let searchFilterKey = "searchFilter" @@ -254,6 +255,14 @@ final class AppState: ObservableObject { } } } + @Published var fileListMode: FileListMode { + didSet { + userDefaults.set(fileListMode.rawValue, forKey: Self.fileListModeKey) + if !isApplyingSnapshot { + currentSettingsSnapshot = snapshot + } + } + } @Published var fitRepositoryWindowsToScreen: Bool { didSet { userDefaults.set(fitRepositoryWindowsToScreen, forKey: Self.fitRepositoryWindowsToScreenKey) @@ -330,6 +339,8 @@ final class AppState: ObservableObject { let historyIncludeRemotes = userDefaults.object(forKey: Self.historyIncludeRemotesKey) as? Bool ?? false let autoFetchEnabled = userDefaults.object(forKey: Self.autoFetchEnabledKey) as? Bool ?? false let refreshOnAppActive = userDefaults.object(forKey: Self.refreshOnAppActiveKey) as? Bool ?? true + let fileListMode = userDefaults.string(forKey: Self.fileListModeKey) + .flatMap(FileListMode.init(rawValue:)) ?? .list let fitRepositoryWindowsToScreen = userDefaults.object( forKey: Self.fitRepositoryWindowsToScreenKey ) as? Bool ?? false @@ -365,6 +376,7 @@ final class AppState: ObservableObject { historyIncludeRemotes: historyIncludeRemotes, autoFetchEnabled: autoFetchEnabled, refreshOnAppActive: refreshOnAppActive, + fileListMode: fileListMode, diffFontName: diffAppearance.fontName, diffFontSize: diffAppearance.fontSize, diffAddedBackgroundColor: diffAppearance.addedBackgroundColor, @@ -399,6 +411,7 @@ final class AppState: ObservableObject { self.historyIncludeRemotes = historyIncludeRemotes self.autoFetchEnabled = autoFetchEnabled self.refreshOnAppActive = refreshOnAppActive + self.fileListMode = fileListMode self.fitRepositoryWindowsToScreen = fitRepositoryWindowsToScreen self.syncEnabled = syncEnabled self.searchFilter = searchFilter @@ -433,6 +446,7 @@ final class AppState: ObservableObject { historyIncludeRemotes: historyIncludeRemotes, autoFetchEnabled: autoFetchEnabled, refreshOnAppActive: refreshOnAppActive, + fileListMode: fileListMode, diffFontName: diffAppearance.fontName, diffFontSize: diffAppearance.fontSize, diffAddedBackgroundColor: diffAppearance.addedBackgroundColor, @@ -481,6 +495,7 @@ final class AppState: ObservableObject { historyIncludeRemotes = snapshot.historyIncludeRemotes autoFetchEnabled = snapshot.autoFetchEnabled refreshOnAppActive = snapshot.refreshOnAppActive + fileListMode = snapshot.fileListMode diffAppearanceSettingsStore.apply(snapshot) advancedSettingsStore.diffSyntaxHighlighting = snapshot.diffSyntaxHighlighting currentSettingsSnapshot = snapshot diff --git a/macgit/App/CreatePullRequestWindowController.swift b/macgit/App/CreatePullRequestWindowController.swift index 9e43099..36b4d15 100644 --- a/macgit/App/CreatePullRequestWindowController.swift +++ b/macgit/App/CreatePullRequestWindowController.swift @@ -25,7 +25,7 @@ final class CreatePullRequestWindowController: NSWindowController, NSWindowDeleg let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800) let size = NSSize( width: min(1080, visibleFrame.width - 40), - height: min(720, visibleFrame.height - 80) + height: min(800, visibleFrame.height - 80) ) let window = NSWindow( contentRect: NSRect(origin: .zero, size: size), @@ -36,12 +36,16 @@ final class CreatePullRequestWindowController: NSWindowController, NSWindowDeleg window.title = "Create Pull Request — \(repositoryName)" window.isReleasedWhenClosed = false window.tabbingMode = .disallowed - window.contentMinSize = NSSize(width: min(820, size.width), height: min(520, size.height)) window.delegate = self - let hostingView = NSHostingView(rootView: content) + let hostingView = NSHostingView(rootView: GeometryReader { geometry in + content + .frame(width: geometry.size.width, height: geometry.size.height) + }) + // Window geometry owns the viewport, including the embedded split views. hostingView.sizingOptions = [] window.contentView = hostingView + window.contentMinSize = NSSize(width: min(820, size.width), height: min(640, size.height)) window.setContentSize(size) window.setFrameOrigin(NSPoint( x: visibleFrame.midX - window.frame.width / 2, diff --git a/macgit/Models/AppSettingsSnapshot.swift b/macgit/Models/AppSettingsSnapshot.swift index cd0f044..47c21c0 100644 --- a/macgit/Models/AppSettingsSnapshot.swift +++ b/macgit/Models/AppSettingsSnapshot.swift @@ -18,6 +18,20 @@ import Foundation +enum FileListMode: String, Codable, CaseIterable, Identifiable, Sendable { + case list + case tree + + var id: Self { self } + + var title: String { + switch self { + case .list: "Plain list" + case .tree: "Tree" + } + } +} + struct AppSettingsSnapshot: Codable, Equatable, Sendable { let schemaVersion: Int var appearance: AppAppearance @@ -44,6 +58,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { var historyIncludeRemotes: Bool var autoFetchEnabled: Bool var refreshOnAppActive: Bool + var fileListMode: FileListMode var diffFontName: String var diffFontSize: Double var diffAddedBackgroundColor: String @@ -77,6 +92,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { historyIncludeRemotes: Bool = false, autoFetchEnabled: Bool = false, refreshOnAppActive: Bool = true, + fileListMode: FileListMode = .list, diffFontName: String = "Menlo-Regular", diffFontSize: Double = 12, diffAddedBackgroundColor: String = "#00A6661F", @@ -110,6 +126,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { self.historyIncludeRemotes = historyIncludeRemotes self.autoFetchEnabled = autoFetchEnabled self.refreshOnAppActive = refreshOnAppActive + self.fileListMode = fileListMode self.diffFontName = diffFontName self.diffFontSize = diffFontSize self.diffAddedBackgroundColor = diffAddedBackgroundColor @@ -146,6 +163,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { historyIncludeRemotes = try container.decode(Bool.self, forKey: .historyIncludeRemotes) autoFetchEnabled = try container.decode(Bool.self, forKey: .autoFetchEnabled) refreshOnAppActive = try container.decode(Bool.self, forKey: .refreshOnAppActive) + fileListMode = try container.decodeIfPresent(FileListMode.self, forKey: .fileListMode) ?? .list diffFontName = try container.decodeIfPresent(String.self, forKey: .diffFontName) ?? "Menlo-Regular" diffFontSize = try container.decodeIfPresent(Double.self, forKey: .diffFontSize) ?? 12 diffAddedBackgroundColor = try container.decodeIfPresent( diff --git a/macgit/Services/FirestoreSettingsStore.swift b/macgit/Services/FirestoreSettingsStore.swift index fa265cb..ce2a704 100644 --- a/macgit/Services/FirestoreSettingsStore.swift +++ b/macgit/Services/FirestoreSettingsStore.swift @@ -50,6 +50,7 @@ enum CloudSettingsDocument { "historyIncludeRemotes": snapshot.historyIncludeRemotes, "autoFetchEnabled": snapshot.autoFetchEnabled, "refreshOnAppActive": snapshot.refreshOnAppActive, + "fileListMode": snapshot.fileListMode.rawValue, "diffFontName": snapshot.diffFontName, "diffFontSize": snapshot.diffFontSize, "diffAddedBackgroundColor": snapshot.diffAddedBackgroundColor, @@ -106,6 +107,9 @@ enum CloudSettingsDocument { let diffSyntaxHighlighting = try optionalBool( data, key: "diffSyntaxHighlighting", defaultValue: false ) + let fileListMode = try optionalString( + data, key: "fileListMode", defaultValue: FileListMode.list.rawValue + ) { FileListMode(rawValue: $0) != nil } return AppSettingsSnapshot( appearance: appearance, @@ -133,6 +137,7 @@ enum CloudSettingsDocument { historyIncludeRemotes: data["historyIncludeRemotes"] as? Bool ?? false, autoFetchEnabled: data["autoFetchEnabled"] as? Bool ?? false, refreshOnAppActive: data["refreshOnAppActive"] as? Bool ?? true, + fileListMode: FileListMode(rawValue: fileListMode) ?? .list, diffFontName: diffFontName, diffFontSize: diffFontSize, diffAddedBackgroundColor: diffAddedBackgroundColor, diff --git a/macgit/Views/Common/FilePathListView.swift b/macgit/Views/Common/FilePathListView.swift new file mode 100644 index 0000000..e207259 --- /dev/null +++ b/macgit/Views/Common/FilePathListView.swift @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct FilePathTreeRow: Identifiable where Item.ID: Hashable { + enum Content { + case folder(path: String, name: String, items: [Item], isExpanded: Bool) + case file(item: Item, name: String) + } + + let id: String + let depth: Int + let content: Content +} + +struct FilePathListView: View where Item.ID: Hashable { + let items: [Item] + let path: (Item) -> String + @ViewBuilder let leading: Leading + @ViewBuilder let trailing: Trailing + @ViewBuilder let plainContent: PlainContent + let treeContent: ([FilePathTreeRow], @escaping (String) -> Void) -> TreeContent + + @EnvironmentObject private var appState: AppState + @State private var roots: [Node] = [] + @State private var visibleRows: [FilePathTreeRow] = [] + @State private var folderPaths: Set = [] + @State private var expandedFolders: Set = [] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + leading + Spacer(minLength: 4) + trailing + if appState.fileListMode == .tree { + Button("Expand all folders", systemImage: "arrow.up.left.and.arrow.down.right", action: expandAll) + .labelStyle(.iconOnly).buttonStyle(.borderless).help("Expand all folders") + Button("Collapse all folders", systemImage: "arrow.down.right.and.arrow.up.left", action: collapseAll) + .labelStyle(.iconOnly).buttonStyle(.borderless).help("Collapse all folders") + } + Button { + appState.fileListMode = appState.fileListMode == .list ? .tree : .list + } label: { + Image(systemName: appState.fileListMode == .list ? "list.bullet" : "list.bullet.indent") + } + .buttonStyle(.borderless) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + .help(appState.fileListMode == .list ? "Switch to tree view" : "Switch to plain list") + .accessibilityLabel(appState.fileListMode == .list ? "Switch to tree view" : "Switch to plain list") + } + .frame(height: 22) + .padding(.leading, 10).padding(.trailing, 8).padding(.vertical, 5) + .background(Color(nsColor: .windowBackgroundColor)) + .overlay(alignment: .bottom) { Divider() } + + Group { + if appState.fileListMode == .list { plainContent } + else { treeContent(visibleRows, toggleFolder) } + } + .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + } + .onAppear { + if appState.fileListMode == .tree && roots.isEmpty && !items.isEmpty { rebuildTree() } + } + .onChange(of: items) { rebuildTree() } + .onChange(of: appState.fileListMode) { + if appState.fileListMode == .tree { rebuildTree() } + } + } + + private func rebuildTree() { + let oldFolders = folderPaths + roots = buildNodes(items) + folderPaths = collectFolderPaths(roots) + expandedFolders.formUnion(folderPaths.subtracting(oldFolders)) + expandedFolders.formIntersection(folderPaths) + rebuildVisibleRows() + } + + private func buildNodes(_ items: [Item]) -> [Node] { + let groups = Dictionary(grouping: items) { item in + String(path(item).split(separator: "/", omittingEmptySubsequences: true).first ?? "") + } + return groups.compactMap { name, groupedItems -> Node? in + guard !name.isEmpty else { return nil } + if let item = groupedItems.first(where: { path($0) == name }) { + return Node(id: name, name: name, item: item, children: []) + } + return buildNode(name: name, fullPath: name, items: groupedItems) + }.sorted(by: nodeOrder) + } + + private func buildNode(name: String, fullPath: String, items: [Item]) -> Node { + let prefix = fullPath + "/" + let groups = Dictionary(grouping: items) { item in + String(path(item).dropFirst(prefix.count).split(separator: "/", omittingEmptySubsequences: true).first ?? "") + } + let children = groups.compactMap { childName, groupedItems -> Node? in + guard !childName.isEmpty else { return nil } + let childPath = prefix + childName + if let item = groupedItems.first(where: { path($0) == childPath }) { + return Node(id: childPath, name: childName, item: item, children: []) + } + return buildNode(name: childName, fullPath: childPath, items: groupedItems) + }.sorted(by: nodeOrder) + return Node(id: fullPath, name: name, item: nil, children: children) + } + + private func nodeOrder(_ lhs: Node, _ rhs: Node) -> Bool { + if (lhs.item == nil) != (rhs.item == nil) { return lhs.item == nil } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + + private func collectFolderPaths(_ nodes: [Node]) -> Set { + var paths = Set() + func collect(_ nodes: [Node]) { + for node in nodes where node.item == nil { paths.insert(node.id); collect(node.children) } + } + collect(nodes) + return paths + } + + private func rebuildVisibleRows() { + var rows: [FilePathTreeRow] = [] + func append(_ nodes: [Node], depth: Int) { + for node in nodes { + if let item = node.item { + rows.append(.init(id: node.id, depth: depth, content: .file(item: item, name: node.name))) + } else { + rows.append(.init(id: node.id, depth: depth, content: .folder( + path: node.id, name: node.name, items: descendantItems(node), + isExpanded: expandedFolders.contains(node.id) + ))) + if expandedFolders.contains(node.id) { append(node.children, depth: depth + 1) } + } + } + } + append(roots, depth: 0) + visibleRows = rows + } + + private func descendantItems(_ node: Node) -> [Item] { + var result: [Item] = [] + func append(_ node: Node) { + if let item = node.item { result.append(item) } else { node.children.forEach(append) } + } + append(node) + return result + } + + private func toggleFolder(_ path: String) { + if expandedFolders.contains(path) { expandedFolders.remove(path) } else { expandedFolders.insert(path) } + rebuildVisibleRows() + } + + private func expandAll() { expandedFolders = folderPaths; rebuildVisibleRows() } + private func collapseAll() { expandedFolders.removeAll(keepingCapacity: true); rebuildVisibleRows() } + + private struct Node { + let id: String + let name: String + let item: Item? + let children: [Node] + } +} diff --git a/macgit/Views/Common/GeneralSettingsView.swift b/macgit/Views/Common/GeneralSettingsView.swift index 6df4232..66fdb72 100644 --- a/macgit/Views/Common/GeneralSettingsView.swift +++ b/macgit/Views/Common/GeneralSettingsView.swift @@ -95,6 +95,19 @@ struct GeneralSettingsView: View { Label("History", systemImage: "clock.arrow.circlepath") } + Section { + Picker("File display", selection: $appState.fileListMode) { + ForEach(FileListMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + } header: { + Label("File Lists", systemImage: "list.bullet.indent") + } footer: { + Text("Controls the Staged, Changed, and commit file lists.") + } + Section { SettingsToggleRow( title: "Auto fetch", @@ -152,6 +165,7 @@ struct GeneralSettingsView: View { appState.showSubmodules = false appState.showSubtrees = false appState.historyIncludeRemotes = false + appState.fileListMode = .list appState.autoFetchEnabled = false appState.refreshOnAppActive = true } diff --git a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift new file mode 100644 index 0000000..1805dbf --- /dev/null +++ b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct FileStatusTreeNativeList: NSViewRepresentable { + struct Row: Equatable, Identifiable { + enum Content: Equatable { + case folder(path: String, name: String, files: [StatusFile], isExpanded: Bool, checkState: NSControl.StateValue) + case file(FileStatusNativeList.Row, name: String) + } + let id: String + let depth: Int + let content: Content + } + + let rows: [Row] + let isStaged: Bool + let textScale: CGFloat + let onToggleFolder: (String) -> Void + let onToggleFolderSelection: ([StatusFile], Bool) -> Void + let onFolderQuickAction: ([StatusFile]) -> Void + let onSelect: (StatusFile, NSEvent.ModifierFlags) -> Void + let onToggleSelection: (StatusFile, Bool) -> Void + let onQuickAction: (StatusFile) -> Void + let onOpenPotentialConflict: (StatusFile) -> Void + let makeMoreMenu: (StatusFile) -> NSMenu + let makeContextMenu: (StatusFile) -> NSMenu + let dragPaths: (StatusFile) -> [String] + let makeDragPayload: ([String]) -> GitDragPayload + let onDrop: ([GitDragPayload]) -> Bool + let onDropTargetChanged: (Bool) -> Void + + func makeCoordinator() -> Coordinator { Coordinator() } + func makeNSView(context: Context) -> NSScrollView { context.coordinator.makeScrollView() } + func updateNSView(_ view: NSScrollView, context: Context) { context.coordinator.apply(self) } + + @MainActor final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { + private let table = StatusTreeTableView() + private var model: FileStatusTreeNativeList? + private var rows: [Row] = [] + private var scale: CGFloat = 0 + private var hostedMenu: NSMenu? + private var isDropTargeted = false + + func makeScrollView() -> NSScrollView { + let scroll = NSScrollView() + scroll.hasVerticalScroller = true; scroll.autohidesScrollers = true; scroll.drawsBackground = false + let column = NSTableColumn(identifier: .init("status-tree")); column.resizingMask = .autoresizingMask + table.addTableColumn(column); table.headerView = nil; table.backgroundColor = .clear + table.rowHeight = 36; table.intercellSpacing = .zero; table.selectionHighlightStyle = .none + table.dataSource = self; table.delegate = self; table.target = self; table.action = #selector(clickedRow) + table.doubleAction = #selector(doubleClickedRow) + table.contextMenuProvider = { [weak self] row in self?.contextMenu(row) } + table.onDraggingExited = { [weak self] in self?.setDropTargeted(false) } + table.registerForDraggedTypes([.init(UTType.macgitGitDragPayload.identifier)]) + table.setDraggingSourceOperationMask(.copy, forLocal: true) + table.setDraggingSourceOperationMask(.copy, forLocal: false) + scroll.documentView = table + return scroll + } + + func apply(_ model: FileStatusTreeNativeList) { + let old = rows + let structureChanged = old.map(\.id) != model.rows.map(\.id) + let scaleChanged = scale != model.textScale + self.model = model; rows = model.rows; scale = model.textScale + if structureChanged || scaleChanged { + let anchor = viewportAnchor(old) + table.reloadData(); restore(anchor) + } else { + var changed = IndexSet() + for index in rows.indices where rows[index] != old[index] { changed.insert(index) } + if !changed.isEmpty { table.reloadData(forRowIndexes: changed, columnIndexes: IndexSet(integer: 0)) } + } + } + + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false } + + func tableView(_ tableView: NSTableView, viewFor column: NSTableColumn?, row: Int) -> NSView? { + guard rows.indices.contains(row), let model else { return nil } + let item = rows[row] + let identifier = NSUserInterfaceItemIdentifier("StatusTreeCell") + let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? StatusTreeCell ?? StatusTreeCell() + cell.identifier = identifier + switch item.content { + case .folder(let path, let name, let files, let expanded, let state): + cell.configureFolder(name: name, depth: item.depth, expanded: expanded, checkState: state, + isStaged: model.isStaged, textScale: model.textScale, + onToggleSelection: { [weak self] selected in self?.model?.onToggleFolderSelection(files, selected) }, + onQuick: { [weak self] in self?.model?.onFolderQuickAction(files) }) + cell.toolTip = path + case .file(let fileRow, let name): + cell.configureFile(row: fileRow, name: name, depth: item.depth, isStaged: model.isStaged, + textScale: model.textScale, + onToggleSelection: { [weak self] selected in self?.model?.onToggleSelection(fileRow.file, selected) }, + onQuick: { [weak self] in self?.model?.onQuickAction(fileRow.file) }, + onMore: { [weak self, weak cell] in + guard let self, let cell else { return } + self.showMenu(self.model?.makeMoreMenu(fileRow.file), at: cell.moreButton) + }, + onConflict: { [weak self] in self?.model?.onOpenPotentialConflict(fileRow.file) }) + } + return cell + } + + @objc private func clickedRow() { + let index = table.clickedRow + guard rows.indices.contains(index), let model else { return } + switch rows[index].content { + case .folder(let path, _, _, _, _): model.onToggleFolder(path) + case .file(let row, _): model.onSelect(row.file, NSApp.currentEvent?.modifierFlags ?? []) + } + } + + @objc private func doubleClickedRow() { + let index = table.clickedRow + guard rows.indices.contains(index), case .file(let row, _) = rows[index].content else { return } + model?.onQuickAction(row.file) + } + + private func contextMenu(_ index: Int) -> NSMenu? { + guard rows.indices.contains(index), case .file(let row, _) = rows[index].content, let model else { return nil } + let menu = model.makeContextMenu(row.file); hostedMenu = menu; menu.delegate = self; return menu + } + private func showMenu(_ menu: NSMenu?, at view: NSView) { + guard let menu else { return }; hostedMenu = menu; menu.delegate = self + menu.popUp(positioning: nil, at: NSPoint(x: view.bounds.maxX, y: view.bounds.minY), in: view) + } + func menuDidClose(_ menu: NSMenu) { if hostedMenu === menu { hostedMenu = nil } } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow index: Int) -> (any NSPasteboardWriting)? { + guard rows.indices.contains(index), case .file(let row, _) = rows[index].content, let model else { return nil } + let paths = model.dragPaths(row.file) + guard !paths.isEmpty else { return nil } + let payload = model.makeDragPayload(paths) + guard let data = try? GitDragPayload.encodeTransferData(payload) else { return nil } + let item = NSPasteboardItem() + guard item.setData(data, forType: .init(UTType.macgitGitDragPayload.identifier)) else { return nil } + GitDragPayloadStore.set(payload) + return item + } + + func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, + proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { + guard decodePayloads(from: info.draggingPasteboard) != nil else { + setDropTargeted(false) + return [] + } + tableView.setDropRow(-1, dropOperation: .on) + setDropTargeted(true) + return .copy + } + + func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, + row: Int, dropOperation: NSTableView.DropOperation) -> Bool { + defer { setDropTargeted(false) } + guard let payloads = decodePayloads(from: info.draggingPasteboard) else { return false } + return model?.onDrop(payloads) == true + } + + private func decodePayloads(from pasteboard: NSPasteboard) -> [GitDragPayload]? { + let type = NSPasteboard.PasteboardType(UTType.macgitGitDragPayload.identifier) + guard let data = pasteboard.data(forType: type), + let payload = try? GitDragPayload.decodeTransferData(data) else { return nil } + return [payload] + } + + private func setDropTargeted(_ targeted: Bool) { + guard isDropTargeted != targeted else { return } + isDropTargeted = targeted + model?.onDropTargetChanged(targeted) + } + + private func viewportAnchor(_ source: [Row]) -> (String, CGFloat)? { + let visible = table.rows(in: table.visibleRect) + guard visible.location != NSNotFound, source.indices.contains(visible.location) else { return nil } + return (source[visible.location].id, table.visibleRect.minY - table.rect(ofRow: visible.location).minY) + } + private func restore(_ anchor: (String, CGFloat)?) { + guard let anchor, let row = rows.firstIndex(where: { $0.id == anchor.0 }), let scroll = table.enclosingScrollView else { return } + scroll.contentView.scroll(to: NSPoint(x: 0, y: max(0, table.rect(ofRow: row).minY + anchor.1))) + scroll.reflectScrolledClipView(scroll.contentView) + } + } +} + +private final class StatusTreeTableView: NSTableView { + var contextMenuProvider: ((Int) -> NSMenu?)? + var onDraggingExited: (() -> Void)? + override func menu(for event: NSEvent) -> NSMenu? { + let index = row(at: convert(event.locationInWindow, from: nil)); return index < 0 ? nil : contextMenuProvider?(index) + } + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + super.draggingExited(sender) + onDraggingExited?() + } +} + +private final class StatusTreeCell: NSTableCellView { + let moreButton = NSButton() + private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let chevron = NSImageView(), icon = NSImageView() + private let name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: "") + private let lfs = NSTextField(labelWithString: "LFS"), conflict = NSButton(), quick = NSButton() + private var onToggle: ((Bool) -> Void)?, onQuick: (() -> Void)?, onMore: (() -> Void)?, onConflict: (() -> Void)? + private var depth = 0 + + override init(frame: NSRect) { + super.init(frame: frame) + [checkbox, chevron, icon, name, added, removed, lfs, conflict, quick, moreButton].forEach(addSubview) + checkbox.setButtonType(.switch); checkbox.isBordered = false; checkbox.controlSize = .small; checkbox.target = self; checkbox.action = #selector(toggle) + chevron.imageScaling = .scaleProportionallyDown; icon.imageScaling = .scaleProportionallyDown + name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed + lfs.textColor = .secondaryLabelColor; lfs.alignment = .center; lfs.wantsLayer = true; lfs.layer?.borderWidth = 1; lfs.layer?.cornerRadius = 3 + [conflict, quick, moreButton].forEach { $0.isBordered = false } + conflict.image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: "Potential conflict"); conflict.contentTintColor = .systemOrange + moreButton.image = NSImage(systemSymbolName: "ellipsis", accessibilityDescription: "More actions") + conflict.target = self; conflict.action = #selector(openConflict); quick.target = self; quick.action = #selector(runQuick); moreButton.target = self; moreButton.action = #selector(openMore) + } + required init?(coder: NSCoder) { fatalError() } + + func configureFolder(name title: String, depth: Int, expanded: Bool, checkState: NSControl.StateValue, + isStaged: Bool, textScale: CGFloat, onToggleSelection: @escaping (Bool) -> Void, + onQuick: @escaping () -> Void) { + self.depth = depth; self.onToggle = onToggleSelection; self.onQuick = onQuick + checkbox.state = checkState; checkbox.allowsMixedState = true + chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil) + icon.image = NSImage(systemSymbolName: expanded ? "folder.fill.badge.minus" : "folder.fill", accessibilityDescription: "Folder"); icon.contentTintColor = .systemBlue + name.stringValue = title; name.font = .systemFont(ofSize: 13 * textScale, weight: .medium) + quick.image = NSImage(systemSymbolName: isStaged ? "minus" : "plus", accessibilityDescription: isStaged ? "Unstage folder" : "Stage folder") + chevron.isHidden = false; added.isHidden = true; removed.isHidden = true; lfs.isHidden = true; conflict.isHidden = true; moreButton.isHidden = true; quick.isHidden = false + needsLayout = true + } + + func configureFile(row: FileStatusNativeList.Row, name title: String, depth: Int, isStaged: Bool, textScale: CGFloat, + onToggleSelection: @escaping (Bool) -> Void, onQuick: @escaping () -> Void, + onMore: @escaping () -> Void, onConflict: @escaping () -> Void) { + self.depth = depth; self.onToggle = onToggleSelection; self.onQuick = onQuick; self.onMore = onMore; self.onConflict = onConflict + checkbox.state = row.isActionSelected ? .on : .off; checkbox.allowsMixedState = false; chevron.isHidden = true + icon.image = NSImage(systemSymbolName: Self.symbol(row.file.status), accessibilityDescription: nil); icon.contentTintColor = Self.color(row.file.status) + name.stringValue = title; name.font = .systemFont(ofSize: 13 * textScale, weight: .medium) + if let count = row.count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true } + let countFont = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = countFont; removed.font = countFont + lfs.isHidden = !row.isLFS; lfs.font = .systemFont(ofSize: 9 * textScale); conflict.isHidden = !row.isPotentialConflict; moreButton.isHidden = false + quick.isHidden = false; quick.image = NSImage(systemSymbolName: isStaged ? "minus" : "plus", accessibilityDescription: isStaged ? "Unstage" : "Stage") + layer?.backgroundColor = row.isActionSelected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor + wantsLayer = true; needsLayout = true + } + + override func layout() { + super.layout(); let y = bounds.midY; let x = CGFloat(depth) * 14 + 6 + checkbox.frame = .init(x: x, y: y - 9, width: 18, height: 18) + chevron.frame = .init(x: x + 22, y: y - 6, width: 10, height: 12) + icon.frame = .init(x: x + (chevron.isHidden ? 22 : 36), y: y - 9, width: 18, height: 18) + var right = bounds.maxX - 6 + moreButton.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); if !moreButton.isHidden { right -= 24 } + quick.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 24 + conflict.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); if !conflict.isHidden { right -= 24 } + if !lfs.isHidden { lfs.frame = .init(x: right - 30, y: y - 9, width: 28, height: 18); right -= 34 } + if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 } + if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 } + name.frame = .init(x: icon.frame.maxX + 7, y: y - 9, width: max(20, right - icon.frame.maxX - 9), height: 18) + } + @objc private func toggle() { onToggle?(checkbox.state == .on) } + @objc private func runQuick() { onQuick?() } + @objc private func openMore() { onMore?() } + @objc private func openConflict() { onConflict?() } + private static func symbol(_ status: FileStatus) -> String { switch status { case .added: "plus.circle.fill"; case .staged, .modified: "pencil.circle.fill"; case .deleted: "minus.circle.fill"; case .renamed: "arrow.right.circle.fill"; case .untracked: "questionmark.circle.fill"; case .conflict: "exclamationmark.triangle.fill" } } + private static func color(_ status: FileStatus) -> NSColor { switch status { case .added: .systemGreen; case .staged, .modified: .systemOrange; case .deleted: .systemRed; case .renamed: .systemBlue; case .untracked: .systemGray; case .conflict: .systemPurple } } +} diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index ef90da8..2da1482 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -389,7 +389,10 @@ struct FileStatusView: View { private var fileListPanel: some View { let selection = actionSelection return VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 0) { + FilePathListView( + items: gitStatus.staged, + path: \.path + ) { HStack(spacing: 8) { TriStateCheckbox(state: sectionCheckState(isStaged: true), accessibilityLabel: "Select all staged") { selectAll in toggleSelectAll(isStaged: true, selectAll: selectAll) @@ -400,7 +403,8 @@ struct FileStatusView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.none) - Spacer() + } + } trailing: { Button(actionSelection.title(for: .staged)) { Task { if actionSelection.selectedStagedFiles.isEmpty { @@ -413,17 +417,10 @@ struct FileStatusView: View { .buttonStyle(GlassButtonStyle(tint: .yellow, fontSize: 10)) .pointingHandCursor() .disabled(gitStatus.staged.isEmpty) - } - .frame(height: 22) - .padding(.leading, 10) - .padding(.trailing, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .windowBackgroundColor)) - .overlay(alignment: .bottom) { - Divider() - } - + } plainContent: { nativeFileList(files: gitStatus.staged, isStaged: true, selection: selection) + } treeContent: { rows, toggleFolder in + nativeTreeList(rows: rows, isStaged: true, selection: selection, toggleFolder: toggleFolder) } .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .contentShape(Rectangle()) @@ -434,7 +431,10 @@ struct FileStatusView: View { Divider() - VStack(alignment: .leading, spacing: 0) { + FilePathListView( + items: changedFiles, + path: \.path + ) { HStack(spacing: 8) { TriStateCheckbox(state: sectionCheckState(isStaged: false), accessibilityLabel: "Select all changed") { selectAll in toggleSelectAll(isStaged: false, selectAll: selectAll) @@ -445,7 +445,8 @@ struct FileStatusView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.none) - Spacer() + } + } trailing: { Button(actionSelection.title(for: .changed)) { Task { if actionSelection.selectedChangedFiles.isEmpty { @@ -458,17 +459,10 @@ struct FileStatusView: View { .buttonStyle(GlassButtonStyle(tint: .accentColor, fontSize: 10)) .pointingHandCursor() .disabled(changedFiles.isEmpty) - } - .frame(height: 22) - .padding(.leading, 10) - .padding(.trailing, 8) - .padding(.vertical, 5) - .background(Color(nsColor: .windowBackgroundColor)) - .overlay(alignment: .bottom) { - Divider() - } - + } plainContent: { nativeFileList(files: changedFiles, isStaged: false, selection: selection) + } treeContent: { rows, toggleFolder in + nativeTreeList(rows: rows, isStaged: false, selection: selection, toggleFolder: toggleFolder) } .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .contentShape(Rectangle()) @@ -480,6 +474,68 @@ struct FileStatusView: View { .background(Color(nsColor: .controlBackgroundColor)) } + private func folderCheckState(files: [StatusFile], isStaged: Bool) -> NSControl.StateValue { + let keys = files.map { FileStatusSelectionKey(file: $0, isStaged: isStaged) } + let selectedCount = keys.count { selectedActionFileKeys.contains($0) } + if selectedCount == 0 { return .off } + if selectedCount == keys.count { return .on } + return .mixed + } + + private func toggleFolderSelection(files: [StatusFile], isStaged: Bool, selectAll: Bool) { + let keys = Set(files.map { FileStatusSelectionKey(file: $0, isStaged: isStaged) }) + if selectAll { selectedActionFileKeys.formUnion(keys) } + else { selectedActionFileKeys.subtract(keys) } + actionSelectionAnchorKey = nil + } + + private func nativeTreeList( + rows: [FilePathTreeRow], + isStaged: Bool, + selection: FileStatusActionSelection, + toggleFolder: @escaping (String) -> Void + ) -> some View { + let counts = isStaged ? stagedLineCounts : changedLineCounts + let sectionLFSPaths = isStaged ? stagedLFSPaths : lfsPaths + let nativeRows = rows.map { row -> FileStatusTreeNativeList.Row in + switch row.content { + case .folder(let path, let name, let files, let expanded): + return .init(id: row.id, depth: row.depth, content: .folder( + path: path, name: name, files: files, isExpanded: expanded, + checkState: folderCheckState(files: files, isStaged: isStaged) + )) + case .file(let file, let name): + let key = FileStatusSelectionKey(file: file, isStaged: isStaged) + return .init(id: row.id, depth: row.depth, content: .file(.init( + file: file, count: counts[file.path], isLFS: sectionLFSPaths.contains(file.path), + isPotentialConflict: hasPotentialConflict(file), + isActionSelected: selectedActionFileKeys.contains(key), isPreviewed: selectedFileKey == key + ), name: name)) + } + } + return FileStatusTreeNativeList( + rows: nativeRows, isStaged: isStaged, textScale: textScale, + onToggleFolder: toggleFolder, + onToggleFolderSelection: { toggleFolderSelection(files: $0, isStaged: isStaged, selectAll: $1) }, + onFolderQuickAction: { files in Task { if isStaged { await unstage(files: files) } else { await stage(files: files) } } }, + onSelect: { selectFileRow(file: $0, isStaged: isStaged, modifierFlags: $1) }, + onToggleSelection: { toggleFileSelection(file: $0, isStaged: isStaged, selected: $1) }, + onQuickAction: { file in Task { if isStaged { await unstage(file: file) } else { await stage(file: file) } } }, + onOpenPotentialConflict: presentPotentialConflictDetails, + makeMoreMenu: { NSHostingMenu(rootView: fileMoreMenuContent(file: $0, isStaged: isStaged, selection: selection)) }, + makeContextMenu: { NSHostingMenu(rootView: completeFileContextMenu(file: $0, isStaged: isStaged, selection: selection)) }, + dragPaths: { selection.dragPaths(startingAt: $0, isStaged: isStaged) }, + makeDragPayload: { .files($0, repositoryURL: repositoryURL) }, + onDrop: { payloads in + handleFileDrop(payloads, into: isStaged ? .staged : .changed) + }, + onDropTargetChanged: { targeted in + if isStaged { isStagedDropTargeted = targeted } + else { isChangedDropTargeted = targeted } + } + ) + } + @ViewBuilder private func nativeFileList( files: [StatusFile], diff --git a/macgit/Views/History/CommitFileListView.swift b/macgit/Views/History/CommitFileListView.swift index cc79265..1c5df57 100644 --- a/macgit/Views/History/CommitFileListView.swift +++ b/macgit/Views/History/CommitFileListView.swift @@ -8,12 +8,46 @@ struct CommitFileListView: View { var lineCounts: [String: FileLineChangeCount] = [:] @Binding var selectedFile: CommitFileChange? var allowsEmptySelection = true + var showsLayoutControls = false var onOpenFile: ((CommitFileChange) -> Void)? = nil var onPatch: (([CommitFileChange], CommitPatchRequest.Direction) -> Void)? = nil var patchDisabledReason: (([CommitFileChange]) -> String?)? = nil @State private var selectedFiles: Set = [] + @ViewBuilder var body: some View { + Group { + if showsLayoutControls { + FilePathListView( + items: changes, + path: \.path + ) { + Text("Files") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + Text("\(changes.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } trailing: { + EmptyView() + } plainContent: { + nativeList + } treeContent: { rows, toggleFolder in + nativeTreeList(rows: rows, toggleFolder: toggleFolder) + } + } else { + nativeList + } + } + .onAppear { synchronizeSelection() } + .onChange(of: changes) { + selectedFiles = selectedFiles.intersection(Set(changes)) + synchronizeSelection() + } + .onChange(of: selectedFile) { synchronizeSelection() } + } + + private var nativeList: some View { CommitFileNativeList( rows: changes.map { .init(change: $0, count: lineCounts[$0.path]) }, selectedIDs: Set(selectedFiles.map(\.id)), @@ -27,12 +61,27 @@ struct CommitFileListView: View { NSHostingMenu(rootView: patchMenu(for: change)) } ) - .onAppear { synchronizeSelection() } - .onChange(of: changes) { - selectedFiles = selectedFiles.intersection(Set(changes)) - synchronizeSelection() - } - .onChange(of: selectedFile) { synchronizeSelection() } + } + + private func nativeTreeList( + rows: [FilePathTreeRow], + toggleFolder: @escaping (String) -> Void + ) -> some View { + CommitFileTreeNativeList( + rows: rows.map { row in + switch row.content { + case .folder(let path, let name, _, let expanded): + return .init(id: row.id, depth: row.depth, content: .folder(path: path, name: name, isExpanded: expanded)) + case .file(let change, let name): + return .init(id: row.id, depth: row.depth, content: .file(change: change, name: name, count: lineCounts[change.path])) + } + }, + selectedIDs: Set(selectedFiles.map(\.id)), textScale: textScale, + onToggleFolder: toggleFolder, onSelect: { file in + selectTreeFile(file, in: rows) + }, onOpenFile: onOpenFile, + makeContextMenu: onPatch == nil ? nil : { change in NSHostingMenu(rootView: patchMenu(for: change)) } + ) } private func updateSelection(_ files: [CommitFileChange], primary: CommitFileChange?) { @@ -44,6 +93,30 @@ struct CommitFileListView: View { } } + private func selectTreeFile(_ file: CommitFileChange, in rows: [FilePathTreeRow]) { + guard onPatch != nil else { + selectedFiles = [file] + selectedFile = file + return + } + let modifiers = (NSApp.currentEvent?.modifierFlags ?? []).intersection(.deviceIndependentFlagsMask) + let visibleFiles = rows.compactMap { row -> CommitFileChange? in + if case .file(let change, _) = row.content { return change } + return nil + } + if modifiers.contains(.shift), let anchor = selectedFile, + let anchorIndex = visibleFiles.firstIndex(of: anchor), let index = visibleFiles.firstIndex(of: file) { + selectedFiles = Set(visibleFiles[min(anchorIndex, index)...max(anchorIndex, index)]) + } else if modifiers.contains(.command) { + if selectedFiles.contains(file) { selectedFiles.remove(file) } + else { selectedFiles.insert(file) } + } else { + selectedFiles = [file] + } + if selectedFiles.contains(file) { selectedFile = file } + else { selectedFile = selectedFiles.first } + } + private func synchronizeSelection() { if let selectedFile, changes.contains(selectedFile) { if onPatch == nil || !selectedFiles.contains(selectedFile) { diff --git a/macgit/Views/History/CommitFileTreeNativeList.swift b/macgit/Views/History/CommitFileTreeNativeList.swift new file mode 100644 index 0000000..19306bb --- /dev/null +++ b/macgit/Views/History/CommitFileTreeNativeList.swift @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit +import SwiftUI + +struct CommitFileTreeNativeList: NSViewRepresentable { + struct Row: Equatable, Identifiable { + enum Content: Equatable { + case folder(path: String, name: String, isExpanded: Bool) + case file(change: CommitFileChange, name: String, count: FileLineChangeCount?) + } + let id: String + let depth: Int + let content: Content + } + let rows: [Row] + let selectedIDs: Set + let textScale: CGFloat + let onToggleFolder: (String) -> Void + let onSelect: (CommitFileChange) -> Void + let onOpenFile: ((CommitFileChange) -> Void)? + let makeContextMenu: ((CommitFileChange) -> NSMenu?)? + + func makeCoordinator() -> Coordinator { Coordinator() } + func makeNSView(context: Context) -> NSScrollView { context.coordinator.makeScrollView() } + func updateNSView(_ view: NSScrollView, context: Context) { context.coordinator.apply(self) } + + @MainActor final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { + private let table = CommitTreeTableView(); private var model: CommitFileTreeNativeList?; private var rows: [Row] = []; private var hostedMenu: NSMenu? + func makeScrollView() -> NSScrollView { + let scroll = NSScrollView(); scroll.hasVerticalScroller = true; scroll.autohidesScrollers = true; scroll.drawsBackground = false + let column = NSTableColumn(identifier: .init("commit-tree")); column.resizingMask = .autoresizingMask + table.addTableColumn(column); table.headerView = nil; table.backgroundColor = .clear; table.rowHeight = 36; table.intercellSpacing = .zero; table.selectionHighlightStyle = .none + table.dataSource = self; table.delegate = self; table.target = self; table.action = #selector(clicked) + table.contextMenuProvider = { [weak self] in self?.contextMenu($0) }; scroll.documentView = table; return scroll + } + func apply(_ model: CommitFileTreeNativeList) { + let old = rows + let oldSelectedIDs = self.model?.selectedIDs ?? [] + let structural = old.map(\.id) != model.rows.map(\.id) + self.model = model; rows = model.rows + if structural { let anchor = viewportAnchor(old); table.reloadData(); restore(anchor) } + else { + var changed = IndexSet() + for i in rows.indices where rows[i] != old[i] + || selection(old[i], selectedIDs: oldSelectedIDs) != selection(rows[i], selectedIDs: model.selectedIDs) { + changed.insert(i) + } + if !changed.isEmpty { table.reloadData(forRowIndexes: changed, columnIndexes: IndexSet(integer: 0)) } + } + } + private func selection(_ row: Row, selectedIDs: Set) -> Bool { if case .file(let change, _, _) = row.content { return selectedIDs.contains(change.id) }; return false } + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false } + func tableView(_ tableView: NSTableView, viewFor column: NSTableColumn?, row: Int) -> NSView? { + guard rows.indices.contains(row), let model else { return nil }; let item = rows[row]; let id = NSUserInterfaceItemIdentifier("CommitTreeCell") + let cell = tableView.makeView(withIdentifier: id, owner: self) as? CommitTreeCell ?? CommitTreeCell(); cell.identifier = id + switch item.content { + case .folder(let path, let name, let expanded): cell.configureFolder(name: name, depth: item.depth, expanded: expanded, textScale: model.textScale); cell.toolTip = path + case .file(let change, let name, let count): cell.configureFile(change: change, name: name, count: count, depth: item.depth, selected: model.selectedIDs.contains(change.id), textScale: model.textScale, showsOpen: model.onOpenFile != nil, onOpen: { [weak self] in self?.model?.onOpenFile?(change) }) + } + return cell + } + @objc private func clicked() { let i = table.clickedRow; guard rows.indices.contains(i), let model else { return }; switch rows[i].content { case .folder(let path, _, _): model.onToggleFolder(path); case .file(let change, _, _): model.onSelect(change) } } + private func contextMenu(_ i: Int) -> NSMenu? { guard rows.indices.contains(i), case .file(let change, _, _) = rows[i].content, let menu = model?.makeContextMenu?(change) else { return nil }; hostedMenu = menu; menu.delegate = self; return menu } + func menuDidClose(_ menu: NSMenu) { if hostedMenu === menu { hostedMenu = nil } } + private func viewportAnchor(_ source: [Row]) -> (String, CGFloat)? { let visible = table.rows(in: table.visibleRect); guard visible.location != NSNotFound, source.indices.contains(visible.location) else { return nil }; return (source[visible.location].id, table.visibleRect.minY - table.rect(ofRow: visible.location).minY) } + private func restore(_ anchor: (String, CGFloat)?) { guard let anchor, let row = rows.firstIndex(where: { $0.id == anchor.0 }), let scroll = table.enclosingScrollView else { return }; scroll.contentView.scroll(to: .init(x: 0, y: max(0, table.rect(ofRow: row).minY + anchor.1))); scroll.reflectScrolledClipView(scroll.contentView) } + } +} + +private final class CommitTreeTableView: NSTableView { var contextMenuProvider: ((Int) -> NSMenu?)?; override func menu(for event: NSEvent) -> NSMenu? { let i = row(at: convert(event.locationInWindow, from: nil)); return i < 0 ? nil : contextMenuProvider?(i) } } + +private final class CommitTreeCell: NSTableCellView { + private let chevron = NSImageView(), icon = NSImageView(), name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: ""), status = NSTextField(labelWithString: ""), open = NSButton() + private var depth = 0; private var onOpen: (() -> Void)? + override init(frame: NSRect) { super.init(frame: frame); [chevron, icon, name, added, removed, status, open].forEach(addSubview); chevron.imageScaling = .scaleProportionallyDown; icon.imageScaling = .scaleProportionallyDown; name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed; status.alignment = .center; open.isBordered = false; open.image = NSImage(systemSymbolName: "arrow.up.forward.app", accessibilityDescription: "Open with application"); open.target = self; open.action = #selector(openFile) } + required init?(coder: NSCoder) { fatalError() } + func configureFolder(name title: String, depth: Int, expanded: Bool, textScale: CGFloat) { self.depth = depth; chevron.isHidden = false; chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil); icon.image = NSImage(systemSymbolName: expanded ? "folder.fill.badge.minus" : "folder.fill", accessibilityDescription: "Folder"); icon.contentTintColor = .systemBlue; name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); added.isHidden = true; removed.isHidden = true; status.isHidden = true; open.isHidden = true; wantsLayer = true; layer?.backgroundColor = NSColor.clear.cgColor; needsLayout = true } + func configureFile(change: CommitFileChange, name title: String, count: FileLineChangeCount?, depth: Int, selected: Bool, textScale: CGFloat, showsOpen: Bool, onOpen: @escaping () -> Void) { self.depth = depth; self.onOpen = onOpen; chevron.isHidden = true; icon.image = NSImage(systemSymbolName: Self.symbol(change.status), accessibilityDescription: nil); icon.contentTintColor = Self.color(change.status); name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); if let count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true }; let font = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = font; removed.font = font; status.stringValue = change.status.rawValue; status.font = font; status.textColor = Self.color(change.status); status.isHidden = showsOpen; open.isHidden = !showsOpen; wantsLayer = true; layer?.backgroundColor = selected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor; needsLayout = true } + override func layout() { super.layout(); let y = bounds.midY, x = CGFloat(depth) * 14 + 8; chevron.frame = .init(x: x, y: y - 6, width: 10, height: 12); icon.frame = .init(x: x + (chevron.isHidden ? 0 : 16), y: y - 9, width: 18, height: 18); var right = bounds.maxX - 8; if !open.isHidden { open.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 26 } else if !status.isHidden { let w = min(68, status.intrinsicContentSize.width + 8); status.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 }; if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; name.frame = .init(x: icon.frame.maxX + 7, y: y - 9, width: max(20, right - icon.frame.maxX - 9), height: 18) } + @objc private func openFile() { onOpen?() } + private static func symbol(_ s: CommitFileStatus) -> String { switch s { case .added: "plus.circle.fill"; case .modified: "pencil.circle.fill"; case .deleted: "minus.circle.fill"; case .renamed: "arrow.right.circle.fill"; case .copied: "doc.on.doc.fill" } } + private static func color(_ s: CommitFileStatus) -> NSColor { switch s { case .added: .systemGreen; case .modified: .systemOrange; case .deleted: .systemRed; case .renamed: .systemBlue; case .copied: .systemPurple } } +} diff --git a/macgit/Views/History/HistoryCommitDetailView.swift b/macgit/Views/History/HistoryCommitDetailView.swift index e30f0cc..5205f58 100644 --- a/macgit/Views/History/HistoryCommitDetailView.swift +++ b/macgit/Views/History/HistoryCommitDetailView.swift @@ -33,6 +33,7 @@ struct HistoryCommitDetailView: View { left: { CommitFileListView(changes: model.fileChanges, lineCounts: model.lineCounts, selectedFile: $model.selectedFile, + showsLayoutControls: true, onOpenFile: { onOpenFile($0.path) }, onPatch: { files, direction in patch.prepare(CommitPatchRequest(commit: commit.hash, files: files, diff --git a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift index 0646ca8..ad886b1 100644 --- a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift +++ b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift @@ -124,7 +124,8 @@ extension MainWindowView { createPullRequestWindow.close() }, onCreate: { await submitCreatePullRequest($0) } - ), + ) + .environmentObject(appState), onClose: { pullRequestController.dismissCreatePullRequest() } ) } diff --git a/macgit/Views/PullRequests/PullRequestDraftChangesView.swift b/macgit/Views/PullRequests/PullRequestDraftChangesView.swift index 7235cd9..205b559 100644 --- a/macgit/Views/PullRequests/PullRequestDraftChangesView.swift +++ b/macgit/Views/PullRequests/PullRequestDraftChangesView.swift @@ -84,20 +84,11 @@ struct PullRequestDraftChangesView: View { GeometryReader { geometry in let availableWidth = max(0, geometry.size.width - 6) HStack(spacing: 0) { - VStack(spacing: 0) { - HStack { - Text("\(changes.count) file\(changes.count == 1 ? "" : "s")") - .font(.subheadline) - .bold() - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(Color(nsColor: .controlBackgroundColor)) - - Divider() - CommitFileListView(changes: changes, selectedFile: $selectedFile) - } + CommitFileListView( + changes: changes, + selectedFile: $selectedFile, + showsLayoutControls: true + ) .frame(width: CGFloat(fileListWidth)) ColumnResizer( diff --git a/macgitTests/AppSettingsSnapshotTests.swift b/macgitTests/AppSettingsSnapshotTests.swift index 2dcf847..70e2451 100644 --- a/macgitTests/AppSettingsSnapshotTests.swift +++ b/macgitTests/AppSettingsSnapshotTests.swift @@ -81,6 +81,7 @@ final class AppSettingsSnapshotTests: XCTestCase { "historyIncludeRemotes", "autoFetchEnabled", "refreshOnAppActive", + "fileListMode", "diffFontName", "diffFontSize", "diffAddedBackgroundColor", diff --git a/macgitTests/CloudSettingsDocumentTests.swift b/macgitTests/CloudSettingsDocumentTests.swift index 5d6cfba..b9a8723 100644 --- a/macgitTests/CloudSettingsDocumentTests.swift +++ b/macgitTests/CloudSettingsDocumentTests.swift @@ -46,6 +46,7 @@ final class CloudSettingsDocumentTests: XCTestCase { historyIncludeRemotes: true, autoFetchEnabled: true, refreshOnAppActive: false, + fileListMode: .tree, diffFontName: "SFMono-Regular", diffFontSize: 14, diffAddedBackgroundColor: "#11223344", @@ -88,6 +89,7 @@ final class CloudSettingsDocumentTests: XCTestCase { "historyIncludeRemotes", "autoFetchEnabled", "refreshOnAppActive", + "fileListMode", "diffFontName", "diffFontSize", "diffAddedBackgroundColor", @@ -123,6 +125,7 @@ final class CloudSettingsDocumentTests: XCTestCase { XCTAssertEqual(document["historyIncludeRemotes"] as? Bool, true) XCTAssertEqual(document["autoFetchEnabled"] as? Bool, true) XCTAssertEqual(document["refreshOnAppActive"] as? Bool, false) + XCTAssertEqual(document["fileListMode"] as? String, "tree") XCTAssertEqual(document["diffFontName"] as? String, "SFMono-Regular") XCTAssertEqual(document["diffFontSize"] as? Double, 14) XCTAssertEqual(document["diffAddedBackgroundColor"] as? String, "#11223344") @@ -216,6 +219,22 @@ final class CloudSettingsDocumentTests: XCTestCase { XCTAssertEqual(decoded.appearance, .system) } + func testDecodingDefaultsMissingFileListModeToList() throws { + var document = validDocument() + document.removeValue(forKey: "fileListMode") + + let decoded = try CloudSettingsDocument.decode(document) + + XCTAssertEqual(decoded.fileListMode, .list) + } + + func testDecodingRejectsInvalidFileListMode() { + var document = validDocument() + document["fileListMode"] = "columns" + + XCTAssertThrowsError(try CloudSettingsDocument.decode(document)) + } + func testDecodingDefaultsMissingDiffSettings() throws { var document = validDocument() document.removeValue(forKey: "diffFontName")