From aa01b25326e18c3e7fbf85c2f9487903a964a08a Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 20:40:23 +0700 Subject: [PATCH 1/4] feat: Add tree view mode for file lists Add FileListMode setting with list and tree options synced through settings snapshot and Firestore. Render staged, changed, and commit file lists as native tree views when tree mode is enabled. --- macgit/App/AppState.swift | 15 ++ macgit/Models/AppSettingsSnapshot.swift | 18 ++ macgit/Services/FirestoreSettingsStore.swift | 5 + macgit/Views/Common/FilePathListView.swift | 167 +++++++++++++ macgit/Views/Common/GeneralSettingsView.swift | 14 ++ .../FileStatus/FileStatusTreeNativeList.swift | 225 ++++++++++++++++++ macgit/Views/FileStatus/FileStatusView.swift | 97 ++++++-- macgit/Views/History/CommitFileListView.swift | 79 +++++- .../History/CommitFileTreeNativeList.swift | 84 +++++++ .../History/HistoryCommitDetailView.swift | 1 + macgitTests/AppSettingsSnapshotTests.swift | 1 + macgitTests/CloudSettingsDocumentTests.swift | 19 ++ 12 files changed, 695 insertions(+), 30 deletions(-) create mode 100644 macgit/Views/Common/FilePathListView.swift create mode 100644 macgit/Views/FileStatus/FileStatusTreeNativeList.swift create mode 100644 macgit/Views/History/CommitFileTreeNativeList.swift diff --git a/macgit/App/AppState.swift b/macgit/App/AppState.swift index c528ad25..f7e02df4 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/Models/AppSettingsSnapshot.swift b/macgit/Models/AppSettingsSnapshot.swift index cd0f0447..47c21c0d 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 fa265cbc..ce2a704c 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 00000000..e207259f --- /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 6df42329..66fdb725 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 00000000..dbca26b5 --- /dev/null +++ b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift @@ -0,0 +1,225 @@ +// 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 + + 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? + + 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.contextMenuProvider = { [weak self] row in self?.contextMenu(row) } + 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 ?? []) + } + } + + 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 + } + + 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?)? + override func menu(for event: NSEvent) -> NSMenu? { + let index = row(at: convert(event.locationInWindow, from: nil)); return index < 0 ? nil : contextMenuProvider?(index) + } +} + +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 ef90da8a..dbe50e07 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,61 @@ 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) } + ) + } + @ViewBuilder private func nativeFileList( files: [StatusFile], diff --git a/macgit/Views/History/CommitFileListView.swift b/macgit/Views/History/CommitFileListView.swift index cc792652..b0385972 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,25 @@ 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: selectTreeFile, onOpenFile: onOpenFile, + makeContextMenu: onPatch == nil ? nil : { change in NSHostingMenu(rootView: patchMenu(for: change)) } + ) } private func updateSelection(_ files: [CommitFileChange], primary: CommitFileChange?) { @@ -44,6 +91,26 @@ struct CommitFileListView: View { } } + private func selectTreeFile(_ file: CommitFileChange) { + guard onPatch != nil else { + selectedFiles = [file] + selectedFile = file + return + } + let modifiers = (NSApp.currentEvent?.modifierFlags ?? []).intersection(.deviceIndependentFlagsMask) + if modifiers.contains(.shift), let anchor = selectedFile, + let anchorIndex = changes.firstIndex(of: anchor), let index = changes.firstIndex(of: file) { + selectedFiles = Set(changes[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 00000000..19306bb1 --- /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 e30f0cc4..5205f583 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/macgitTests/AppSettingsSnapshotTests.swift b/macgitTests/AppSettingsSnapshotTests.swift index 2dcf847a..70e2451d 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 5d6cfba2..b9a8723b 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") From a31db98ea1be641e99d1b06af2f0c89cbfb7aec3 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 20:43:33 +0700 Subject: [PATCH 2/4] fix: Constrain PR window content to viewport and raise min height Wrap the create pull request content in a GeometryReader so split views fill the window, enforce the minimum size after the hosting view is installed, and increase the default and minimum window heights. --- macgit/App/CreatePullRequestWindowController.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/macgit/App/CreatePullRequestWindowController.swift b/macgit/App/CreatePullRequestWindowController.swift index 9e430998..36b4d152 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, From b0b36c9d64e6d84f39703b17f853201bad0d8ed1 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 20:52:57 +0700 Subject: [PATCH 3/4] feat: add tree view mode for create pr screen --- .../MainWindowView+PullRequestActions.swift | 3 ++- .../PullRequestDraftChangesView.swift | 19 +++++-------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift index 0646ca8c..ad886b1a 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 7235cd94..205b5599 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( From 1ed65f278d493d3a675bba7a82d98dbcf4e3f22c Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 21:03:19 +0700 Subject: [PATCH 4/4] fix: restore tree file list interactions --- .../FileStatus/FileStatusTreeNativeList.swift | 47 +++++++++++++++++++ macgit/Views/FileStatus/FileStatusView.swift | 9 +++- macgit/Views/History/CommitFileListView.swift | 14 ++++-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift index dbca26b5..1805dbfe 100644 --- a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift +++ b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift @@ -28,6 +28,8 @@ struct FileStatusTreeNativeList: NSViewRepresentable { 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() } @@ -39,6 +41,7 @@ struct FileStatusTreeNativeList: NSViewRepresentable { private var rows: [Row] = [] private var scale: CGFloat = 0 private var hostedMenu: NSMenu? + private var isDropTargeted = false func makeScrollView() -> NSScrollView { let scroll = NSScrollView() @@ -47,7 +50,9 @@ struct FileStatusTreeNativeList: NSViewRepresentable { 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) @@ -109,6 +114,12 @@ struct FileStatusTreeNativeList: NSViewRepresentable { } } + @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 @@ -131,6 +142,37 @@ struct FileStatusTreeNativeList: NSViewRepresentable { 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 } @@ -146,9 +188,14 @@ struct FileStatusTreeNativeList: NSViewRepresentable { 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 { diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index dbe50e07..2da14829 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -525,7 +525,14 @@ struct FileStatusView: View { 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) } + makeDragPayload: { .files($0, repositoryURL: repositoryURL) }, + onDrop: { payloads in + handleFileDrop(payloads, into: isStaged ? .staged : .changed) + }, + onDropTargetChanged: { targeted in + if isStaged { isStagedDropTargeted = targeted } + else { isChangedDropTargeted = targeted } + } ) } diff --git a/macgit/Views/History/CommitFileListView.swift b/macgit/Views/History/CommitFileListView.swift index b0385972..1c5df571 100644 --- a/macgit/Views/History/CommitFileListView.swift +++ b/macgit/Views/History/CommitFileListView.swift @@ -77,7 +77,9 @@ struct CommitFileListView: View { } }, selectedIDs: Set(selectedFiles.map(\.id)), textScale: textScale, - onToggleFolder: toggleFolder, onSelect: selectTreeFile, onOpenFile: onOpenFile, + onToggleFolder: toggleFolder, onSelect: { file in + selectTreeFile(file, in: rows) + }, onOpenFile: onOpenFile, makeContextMenu: onPatch == nil ? nil : { change in NSHostingMenu(rootView: patchMenu(for: change)) } ) } @@ -91,16 +93,20 @@ struct CommitFileListView: View { } } - private func selectTreeFile(_ file: CommitFileChange) { + 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 = changes.firstIndex(of: anchor), let index = changes.firstIndex(of: file) { - selectedFiles = Set(changes[min(anchorIndex, index)...max(anchorIndex, index)]) + 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) }