diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde0e3d..ba26f59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,6 @@ jobs: - name: Build app run: just build + + - name: Test app + run: just test-app diff --git a/AGENTS.md b/AGENTS.md index bd7cf40..8a66c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ shell/mac/Sources/JayJay/ Window/ RepoWindowManager, RepositoryCommands, RepositoryFocus, RepositoryActions Watcher/ RepoFSWatcher JayJayApp.swift, CLIInstaller.swift, DebugBadge.swift, LaunchArguments.swift, SparkleUpdater.swift - Repo/ RepoWindow, RepoSidebar, RepoViewModel, RepoViewModel+Actions, DAGView, DAGLayout, DAGRow, CommitBox, BookmarkPicker, UndoView + Repo/ RepoWindow, RepoSidebar, RepoViewModel, RepoViewModel+Actions, DAGView, DAGLayout, DAGRow, DAGRowViewModel, RepoPresentation, RepoToast, CommitBox, BookmarkPicker, UndoView Detail/ DetailView, DetailHeader, FileColumn, FileListView, AnnotateView, FileHistoryView Diff/ DiffSection, DiffColors, NativeDiffView, SideBySideDiffView Onboarding/ OnboardingView, WelcomeView @@ -76,6 +76,17 @@ shell/mac/Sources/JayJay/ Each file should be **under 300 lines**. If it grows beyond that, split by responsibility. +## Presentation Surfaces + +Use repo-level presentation types from `RepoPresentation.swift` instead of ad hoc booleans. + +- **Inline state** — Use inline empty/error views for pane-scoped no-data, first-run guidance, and recoverable section errors. If the rest of the window can stay interactive, keep it inline. +- **Toast** (`RepoOverlayState.toast` / `RepoToast`) — Use for non-blocking action feedback, success messages, conflict follow-up, and lightweight warnings. Keep it short and allow at most one direct action such as Undo. +- **HUD** (`RepoOverlayState.loading`) — Use only for temporary blocking busy states where further interaction would be misleading or unsafe. Prefer quiet refreshes over showing a HUD. +- **Alert** (`RepoAlertState`) — Use for short blocking interruptions that need acknowledgement or a simple binary choice. No forms, no long copy, no more than two meaningful actions. +- **Sheet** (`RepoModalState` + `SheetContainer`) — Use for forms, previews, richer explanations, multi-step flows, or confirmations that need more context than an alert. +- **Do not escalate inline states** into alerts or sheets just because they are errors. Scope the surface to the scope of the problem. + ## Version Control This repo uses **Jujutsu (jj)**, not git. All version control operations should use `jj` commands. diff --git a/Cargo.lock b/Cargo.lock index 8adfc91..a7cf6fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1734,7 +1734,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jayjay-cli" -version = "0.2.11" +version = "0.2.12" dependencies = [ "clap", "urlencoding", diff --git a/crates/jayjay-cli/Cargo.toml b/crates/jayjay-cli/Cargo.toml index 46c0206..ad41fff 100644 --- a/crates/jayjay-cli/Cargo.toml +++ b/crates/jayjay-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jayjay-cli" -version = "0.2.11" +version = "0.2.12" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/justfile b/justfile index d1b5691..f498661 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,7 @@ default: list: @echo "just list Show available commands" @echo "just test Run Rust tests" + @echo "just test-app Run macOS app tests" @echo "just format Format Rust and Swift sources" @echo "just lint Lint Rust (clippy) and Swift (swiftlint)" @echo "just clean Remove generated build artifacts" @@ -23,6 +24,9 @@ list: test: cargo test --workspace +test-app: + just shell::test + build: just shell::build diff --git a/releases/0.2.12.html b/releases/0.2.12.html new file mode 100644 index 0000000..7f234a1 --- /dev/null +++ b/releases/0.2.12.html @@ -0,0 +1,19 @@ +

Drag-to-rebase in the DAG

+ +

DAG interaction polish

+ +

Internal cleanup

+ diff --git a/shell/justfile b/shell/justfile index b73ef07..4b40d8b 100644 --- a/shell/justfile +++ b/shell/justfile @@ -24,8 +24,8 @@ signing_identity := "Developer ID Application: Tao Xu (V28VJH6B6S)" team_id := "V28VJH6B6S" bundle_id := "dev.hewig.jayjay" app_name := "JayJay" -version := "0.2.11" -build_number := "15" +version := "0.2.12" +build_number := "16" release_dir := root / "build" / "release" default: list @@ -35,6 +35,7 @@ list: @echo " just shell::ffi Rebuild Rust FFI, bindings, and XCFramework (debug)" @echo " just shell::ffi-release Rebuild Rust FFI, bindings, and XCFramework (release)" @echo " just shell::build Build the macOS app" + @echo " just shell::test Run the macOS app unit tests" @echo " just shell::run Build and launch the app" @echo " just shell::run /path/to/repo Build and launch the app for a repo" @echo " just shell::format Format Swift sources" @@ -104,6 +105,16 @@ build: ffi sync-icon codesign --force --sign - "{{app}}" @echo "Built {{app}}" +test: ffi sync-icon + xcodegen --spec "{{project}}/project.yml" --project "{{project}}" + xcodebuild \ + -project "{{project}}/JayJay.xcodeproj" \ + -scheme JayJay \ + -configuration Debug \ + -sdk macosx \ + -derivedDataPath "{{derived_data}}" \ + test | xcbeautify + run repo='': build @killall JayJay 2>/dev/null || true @if [[ -n "{{repo}}" ]]; then \ diff --git a/shell/mac/Sources/JayJay/App/Config/AppSettings.swift b/shell/mac/Sources/JayJay/App/Config/AppSettings.swift index aee9508..f999852 100644 --- a/shell/mac/Sources/JayJay/App/Config/AppSettings.swift +++ b/shell/mac/Sources/JayJay/App/Config/AppSettings.swift @@ -17,6 +17,7 @@ final class AppSettings { static let lastOpenedRepo = "jayjay.lastOpenedRepo" static let hasCompletedOnboarding = "jayjay.hasCompletedOnboarding" static let skipAbandonConfirmation = "jayjay.skipAbandonConfirmation" + static let confirmDragRebase = "jayjay.confirmDragRebase" static let externalEditor = "jayjay.externalEditor" static let customEditorCommand = "jayjay.customEditorCommand" static let terminal = "jayjay.terminal" @@ -74,6 +75,10 @@ final class AppSettings { ) } } + var confirmDragRebase: Bool { + didSet { defaults.set(confirmDragRebase, forKey: StorageKeys.confirmDragRebase) } + } + // MARK: - Layout var sidebarWidth: Double { @@ -151,6 +156,7 @@ final class AppSettings { enableGitSubmoduleSupport = defaults.object(forKey: StorageKeys.enableGitSubmoduleSupport) as? Bool ?? false treeFileList = defaults.bool(forKey: StorageKeys.treeFileList) skipAbandonConfirmation = defaults.bool(forKey: StorageKeys.skipAbandonConfirmation) + confirmDragRebase = defaults.object(forKey: StorageKeys.confirmDragRebase) as? Bool ?? true sidebarWidth = min(max(defaults.object(forKey: StorageKeys.sidebarWidth) as? Double ?? 360, 240), 600) recentRepos = (defaults.stringArray(forKey: StorageKeys.recentRepos) ?? []).filter { !$0.isEmpty } lastOpenedRepo = defaults.string(forKey: StorageKeys.lastOpenedRepo) diff --git a/shell/mac/Sources/JayJay/App/JayJayApp.swift b/shell/mac/Sources/JayJay/App/JayJayApp.swift index 52923b9..025e873 100644 --- a/shell/mac/Sources/JayJay/App/JayJayApp.swift +++ b/shell/mac/Sources/JayJay/App/JayJayApp.swift @@ -30,6 +30,7 @@ struct JayJayApp: App { .environment(\.jayjayFontFamily, settings.fontFamily) .preferredColorScheme(settings.appearanceMode.colorScheme) .onAppear { + appDelegate.openRepositoryPicker = { openRepo() } appDelegate.openHandler = { openRepo(path: $0) } appDelegate.showRepoSelector = { repoPath = nil } appDelegate.recentReposProvider = { [settings] in settings.recentRepos } @@ -242,15 +243,21 @@ private func findDiffTextView(in view: NSView?) -> NSTextView? { } class AppDelegate: NSObject, NSApplicationDelegate { + var openRepositoryPicker: (() -> Void)? var openHandler: ((String) -> Void)? var showRepoSelector: (() -> Void)? var recentReposProvider: (() -> [String])? func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + let menu = NSMenu() + let openItem = NSMenuItem(title: "Open Repository...", action: #selector(dockMenuOpenRepositoryPicker), keyEquivalent: "") + openItem.target = self + menu.addItem(openItem) + let repos = recentReposProvider?() ?? [] - guard !repos.isEmpty else { return nil } + guard !repos.isEmpty else { return menu } - let menu = NSMenu() + menu.addItem(.separator()) let submenu = NSMenu(title: "Recent Repositories") for path in repos { let name = URL(fileURLWithPath: path).lastPathComponent @@ -265,6 +272,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { return menu } + @objc private func dockMenuOpenRepositoryPicker() { + openRepositoryPicker?() + } + @objc private func dockMenuOpenRepo(_ sender: NSMenuItem) { guard let path = sender.representedObject as? String else { return } openHandler?(path) diff --git a/shell/mac/Sources/JayJay/Detail/DetailView.swift b/shell/mac/Sources/JayJay/Detail/DetailView.swift index d49c3c6..640b5e8 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailView.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailView.swift @@ -11,6 +11,7 @@ struct DetailView: View { let diffStore: DiffStore var compareFromId: String? var onClearCompare: (() -> Void)? + var onRevealChangeInDag: ((String) -> Void)? @Binding var activePane: ActivePane var body: some View { @@ -21,6 +22,7 @@ struct DetailView: View { reviewStore: reviewStore, diffStore: diffStore, compareFromId: compareFromId, onClearCompare: onClearCompare, + onRevealChangeInDag: onRevealChangeInDag, activePane: $activePane ) .id(detail.info.changeId) @@ -43,6 +45,7 @@ struct ChangeDetailView: View { let diffStore: DiffStore var compareFromId: String? var onClearCompare: (() -> Void)? + var onRevealChangeInDag: ((String) -> Void)? @Binding var activePane: ActivePane var isCompareMode: Bool { @@ -227,7 +230,11 @@ struct ChangeDetailView: View { onSelectChange: { changeId in annotatePath = nil annotateLines = nil - actions?.select(changeId: changeId) + if let onRevealChangeInDag { + onRevealChangeInDag(changeId) + } else { + actions?.select(changeId: changeId) + } }, onDismiss: { annotatePath = nil annotateLines = nil @@ -240,7 +247,11 @@ struct ChangeDetailView: View { onSelectChange: { changeId in fileHistoryPath = nil fileHistory = nil - actions?.select(changeId: changeId) + if let onRevealChangeInDag { + onRevealChangeInDag(changeId) + } else { + actions?.select(changeId: changeId) + } }, onDismiss: { fileHistoryPath = nil fileHistory = nil diff --git a/shell/mac/Sources/JayJay/Repo/DAGLayout.swift b/shell/mac/Sources/JayJay/Repo/DAGLayout.swift index e4e5a7b..10119ec 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGLayout.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGLayout.swift @@ -3,6 +3,9 @@ import SwiftUI let laneWidth: CGFloat = 16 let nodeRadius: CGFloat = 4 +let dagRowLeadingPadding: CGFloat = 4 +let dagRowVerticalPadding: CGFloat = 8 +let dagNodeCenterY: CGFloat = 12 /// Pre-computes which lane (column) each commit occupies. struct DAGLayout { diff --git a/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift b/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift new file mode 100644 index 0000000..36c81b3 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift @@ -0,0 +1,122 @@ +import CoreGraphics +import JayJayCore + +enum DAGRebaseGestureChangeAction: Equatable { + case ignore + case beginPress + case cancelPress + case beginDragging + case updateDragging +} + +enum DAGRebaseGestureEndAction: Equatable { + case ignore + case select + case cancel + case confirmDrop +} + +enum DAGRebaseGesturePolicy { + static let armDuration = 0.75 + static let previewDelayMs = 500 + static let pressMoveTolerance: CGFloat = 8 + static let dragStartDistance: CGFloat = 2 + + static func dropRequest( + rebaseDrag: DAGRebaseDragState?, + previewTargetCommitId: String?, + hoveredCommitId: String?, + entries: [GraphEntry] + ) -> DAGRebaseRequest? { + guard let rebaseDrag, + let targetCommitId = previewTargetCommitId ?? hoveredCommitId, + targetCommitId != rebaseDrag.sourceCommitId, + let targetEntry = entries.first(where: { $0.change.commitId == targetCommitId }) + else { + return nil + } + + return DAGRebaseRequest( + sourceRev: rebaseDrag.sourceRev, + sourceChangeId: rebaseDrag.sourceChangeId, + sourceCommitId: rebaseDrag.sourceCommitId, + sourceLabel: rebaseDrag.sourceLabel, + destRev: revision(for: targetEntry.change), + destChangeId: targetEntry.change.changeId, + destCommitId: targetEntry.change.commitId, + destLabel: displayLabel(for: targetEntry.change) + ) + } + + static func changeAction( + entryIsImmutable: Bool, + sourceCommitId: String, + rebaseDrag: DAGRebaseDragState?, + location: CGPoint + ) -> DAGRebaseGestureChangeAction { + guard !entryIsImmutable else { return .ignore } + guard let rebaseDrag else { return .beginPress } + guard rebaseDrag.sourceCommitId == sourceCommitId else { return .beginPress } + + let movement = movementDistance(from: rebaseDrag.startLocation, to: location) + switch rebaseDrag.phase { + case .pressing: + return movement > pressMoveTolerance ? .cancelPress : .ignore + case .armed: + return movement >= dragStartDistance ? .beginDragging : .ignore + case .dragging: + return .updateDragging + } + } + + static func endAction( + entryIsImmutable: Bool, + sourceCommitId: String, + rebaseDrag: DAGRebaseDragState?, + startLocation: CGPoint, + location: CGPoint + ) -> DAGRebaseGestureEndAction { + if entryIsImmutable { + return movementDistance(from: startLocation, to: location) <= pressMoveTolerance ? .select : .ignore + } + + guard let rebaseDrag, rebaseDrag.sourceCommitId == sourceCommitId else { return .ignore } + switch rebaseDrag.phase { + case .pressing: + return .select + case .armed: + return .cancel + case .dragging: + return .confirmDrop + } + } + + static func movementDistance(from startLocation: CGPoint, to location: CGPoint) -> CGFloat { + hypot(location.x - startLocation.x, location.y - startLocation.y) + } + + static func normalizedTargetCommitId(sourceCommitId: String, hoveredCommitId: String?) -> String? { + hoveredCommitId == sourceCommitId ? nil : hoveredCommitId + } + + static func revision(for change: ChangeInfo) -> String { + change.isDivergent ? change.commitId : change.changeId + } + + static func displayLabel(for change: ChangeInfo) -> String { + if let bookmark = change.bookmarks.first, !bookmark.isEmpty { + return bookmark + } + let firstLine = change.description + .components(separatedBy: "\n") + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !firstLine.isEmpty { + return firstLine + } + if change.isWorkingCopy { + return "@" + } + return String(change.changeId.prefix(8)) + } +} diff --git a/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift b/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift new file mode 100644 index 0000000..0766b80 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift @@ -0,0 +1,30 @@ +import CoreGraphics +import Foundation + +struct DAGRebaseRequest: Identifiable { + let id = UUID() + let sourceRev: String + let sourceChangeId: String + let sourceCommitId: String + let sourceLabel: String + let destRev: String + let destChangeId: String + let destCommitId: String + let destLabel: String +} + +enum DAGRebasePhase { + case pressing, armed, dragging +} + +struct DAGRebaseDragState { + let sourceCommitId: String + let sourceChangeId: String + let sourceRev: String + let sourceLabel: String + let startLocation: CGPoint + var armedAt: Date? + var phase: DAGRebasePhase + var location: CGPoint + var hoveredCommitId: String? +} diff --git a/shell/mac/Sources/JayJay/Repo/DAGRow.swift b/shell/mac/Sources/JayJay/Repo/DAGRow.swift index b20133e..b3f4e4a 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGRow.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGRow.swift @@ -3,31 +3,34 @@ import JayJayCore import SwiftUI struct DAGRow: View { - let entry: GraphEntry - let layout: DAGLayout - let index: Int - let isSelected: Bool - let isCompareSource: Bool - var isContextTarget: Bool = false - let isLast: Bool - let colorScheme: ColorScheme + let viewModel: DAGRowViewModel var onMoveBookmarkForward: ((String) -> Void)? var onPushBookmark: ((String) -> Void)? private var change: ChangeInfo { - entry.change + viewModel.change } var body: some View { + if viewModel.isRebaseArmed { + TimelineView(.animation) { timeline in + rowBody(wiggleAngle: viewModel.wiggleAngle(at: timeline.date)) + } + } else { + rowBody(wiggleAngle: 0) + } + } + + private func rowBody(wiggleAngle: Double) -> some View { HStack(alignment: .top, spacing: 0) { graphColumn - .frame(width: min(160, CGFloat(max(layout.maxLanes(), 1)) * laneWidth + 8)) + .frame(width: viewModel.graphWidth) VStack(alignment: .leading, spacing: 5) { HStack(spacing: 4) { Text(shortId(change.changeId)) .jayjayFont(11, weight: .semibold, design: .monospaced) - .foregroundStyle(change.isWorkingCopy ? Color.accentColor : .secondary) + .foregroundStyle(viewModel.changeIdColor) .lineLimit(1) if change.isWorkingCopy { tag("@", tint: .accentColor.opacity(0.18)) } if change.hasConflict { tag("conflict", tint: .red.opacity(0.18)) } @@ -42,9 +45,8 @@ struct DAGRow: View { } .lineLimit(1) - if !change.description.isEmpty { - let firstLine = change.description.components(separatedBy: "\n").first ?? "" - Text(firstLine) + if let descriptionLine = viewModel.descriptionLine { + Text(descriptionLine) .jayjayFont(13, weight: .medium).lineLimit(1) .help(change.description) } else { @@ -60,42 +62,54 @@ struct DAGRow: View { .padding(.trailing, 10) Spacer(minLength: 0) } - .padding(.vertical, 8) - .padding(.leading, 4) - .background(rowBackground) + .padding(.vertical, dagRowVerticalPadding) + .padding(.leading, dagRowLeadingPadding) + .background(viewModel.rowBackground) + .rotationEffect(.degrees(wiggleAngle)) + .scaleEffect(viewModel.scale) + .opacity(viewModel.opacity) .overlay(alignment: .leading) { - if isSelected { - RoundedRectangle(cornerRadius: 2, style: .continuous).fill(Color.accentColor).frame(width: 3) - } else if isCompareSource { - RoundedRectangle(cornerRadius: 2, style: .continuous).fill(Color.orange).frame(width: 3) - } else if isContextTarget { - RoundedRectangle(cornerRadius: 2, style: .continuous).fill(Color.secondary).frame(width: 3) + if let accent = viewModel.leadingAccentColor { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(accent) + .frame(width: 3) } } - } - - private var rowBackground: some ShapeStyle { - if isSelected { - AnyShapeStyle(Color.accentColor.opacity(colorScheme == .dark ? 0.18 : 0.10)) - } else if isCompareSource { - AnyShapeStyle(Color.orange.opacity(colorScheme == .dark ? 0.15 : 0.08)) - } else if isContextTarget { - AnyShapeStyle(Color.secondary.opacity(colorScheme == .dark ? 0.12 : 0.06)) - } else { - AnyShapeStyle(.clear) + .overlay { + switch viewModel.outlineState { + case .hoverTarget?: + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.accentColor, lineWidth: 2) + .padding(.vertical, 2) + case .armed?: + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke( + Color.accentColor.opacity(0.7), + style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]) + ) + .padding(.vertical, 2) + case nil: + EmptyView() + } + } + .overlay(alignment: .trailing) { + if let dragTargetText = viewModel.dragTargetText { + dragTargetBubble(dragTargetText) + .padding(.trailing, 10) + } } } private var graphColumn: some View { GeometryReader { geo in - let myLane = layout.lane(for: change.commitId) + let myLane = viewModel.layout.lane(for: change.commitId) let myX = CGFloat(myLane) * laneWidth + laneWidth / 2 + 4 - let nodeY: CGFloat = 12 + let nodeY = dagNodeCenterY let height = geo.size.height Canvas { ctx, _ in // Draw vertical continuation lines for all active lanes - for lane in layout.activeLaneIndices(at: index) where lane != myLane { + for lane in viewModel.layout.activeLaneIndices(at: viewModel.index) where lane != myLane { let laneX = CGFloat(lane) * laneWidth + laneWidth / 2 + 4 let path = Path { p in p.move(to: CGPoint(x: laneX, y: 0)) @@ -105,9 +119,9 @@ struct DAGRow: View { } // Draw edges from this node to its parents - for edge in entry.edges { + for edge in viewModel.entry.edges { if edge.edgeType == .missing { continue } - let targetLane = layout.lane(for: edge.target) + let targetLane = viewModel.layout.lane(for: edge.target) let targetX = CGFloat(targetLane) * laneWidth + laneWidth / 2 + 4 let path = Path { p in @@ -148,6 +162,36 @@ struct DAGRow: View { } else { ctx.fill(nodePath, with: .color(.secondary.opacity(0.5))) } + + if viewModel.isRebaseCandidate { + ctx.stroke( + nodePath, + with: .color(.accentColor.opacity(viewModel.isRebaseHoverTarget ? 1 : 0.55)), + style: StrokeStyle(lineWidth: viewModel.isRebaseHoverTarget ? 2.5 : 1.4) + ) + if viewModel.isRebaseHoverTarget { + let ringRect = nodeRect.insetBy(dx: -4, dy: -4) + ctx.stroke( + Path(ellipseIn: ringRect), + with: .color(.accentColor.opacity(0.45)), + style: StrokeStyle(lineWidth: 2) + ) + } + } else if viewModel.isRebaseSource { + ctx.stroke( + nodePath, + with: .color(.accentColor.opacity(0.75)), + style: StrokeStyle(lineWidth: 2) + ) + if viewModel.isRebaseArmed { + let ringRect = nodeRect.insetBy(dx: -3, dy: -3) + ctx.stroke( + Path(ellipseIn: ringRect), + with: .color(.accentColor.opacity(0.35)), + style: StrokeStyle(lineWidth: 1.5, dash: [3, 3]) + ) + } + } } } } @@ -182,4 +226,36 @@ struct DAGRow: View { private func shortId(_ id: String) -> String { String(id.prefix(12)) } + + private func dragTargetBubble(_ text: String) -> some View { + HStack(spacing: 6) { + Text(text) + .jayjayFont(10, weight: .medium) + .lineLimit(1) + if viewModel.showsReturnHint { + hintChip("return") + } + hintChip("esc") + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + viewModel.isRebaseHoverTarget ? Color.accentColor.opacity(0.14) : Color.clear, + in: Capsule() + ) + .background(.regularMaterial, in: Capsule()) + .overlay( + Capsule() + .stroke(Color.accentColor.opacity(viewModel.isRebaseHoverTarget ? 0.5 : 0.2), lineWidth: 1) + ) + } + + private func hintChip(_ text: String) -> some View { + Text(text.uppercased()) + .jayjayFont(8, weight: .semibold, design: .monospaced) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.06), in: Capsule()) + } } diff --git a/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift b/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift new file mode 100644 index 0000000..99c9288 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift @@ -0,0 +1,213 @@ +import JayJayCore +import SwiftUI + +enum DAGRowSelectionAccent: Equatable { + case selected + case compareSource + case contextTarget + + var color: Color { + switch self { + case .selected: .accentColor + case .compareSource: .orange + case .contextTarget: .secondary + } + } +} + +enum DAGRowOutlineState: Equatable { + case armed + case hoverTarget +} + +enum DAGRowRebaseState: Equatable { + case none + case sourceArmed(armedAt: Date?) + case sourceDragging + case candidate + case hoverTarget(previewText: String?) +} + +struct DAGRowViewModel { + let entry: GraphEntry + let layout: DAGLayout + let index: Int + let colorScheme: ColorScheme + let selectionAccent: DAGRowSelectionAccent? + let rebaseState: DAGRowRebaseState + + init( + entry: GraphEntry, + layout: DAGLayout, + index: Int, + selectedId: String?, + compareFromId: String?, + contextTargetId: String?, + rebaseDrag: DAGRebaseDragState?, + rebasePreviewText: String?, + colorScheme: ColorScheme + ) { + self.entry = entry + self.layout = layout + self.index = index + self.colorScheme = colorScheme + + if selectedId == entry.change.changeId { + selectionAccent = .selected + } else if compareFromId == entry.change.changeId { + selectionAccent = .compareSource + } else if contextTargetId == entry.change.changeId { + selectionAccent = .contextTarget + } else { + selectionAccent = nil + } + + if rebaseDrag?.sourceCommitId == entry.change.commitId { + switch rebaseDrag?.phase { + case .pressing?: + rebaseState = .none + case .armed?: + rebaseState = .sourceArmed(armedAt: rebaseDrag?.armedAt) + case .dragging?: + rebaseState = .sourceDragging + case nil: + rebaseState = .none + } + } else if rebaseDrag != nil { + if rebaseDrag?.hoveredCommitId == entry.change.commitId { + rebaseState = .hoverTarget(previewText: rebasePreviewText) + } else { + rebaseState = .candidate + } + } else { + rebaseState = .none + } + } + + var change: ChangeInfo { + entry.change + } + + var graphWidth: CGFloat { + min(160, CGFloat(max(layout.maxLanes(), 1)) * laneWidth + 8) + } + + var changeIdColor: Color { + change.isWorkingCopy ? .accentColor : .secondary + } + + var descriptionLine: String? { + let line = change.description.components(separatedBy: "\n").first ?? "" + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var rowBackground: AnyShapeStyle { + if isRebaseHoverTarget { + return AnyShapeStyle(Color.accentColor.opacity(colorScheme == .dark ? 0.18 : 0.10)) + } + if isRebaseArmed { + return AnyShapeStyle(Color.accentColor.opacity(colorScheme == .dark ? 0.14 : 0.08)) + } + switch selectionAccent { + case .selected?: + return AnyShapeStyle(Color.accentColor.opacity(colorScheme == .dark ? 0.18 : 0.10)) + case .compareSource?: + return AnyShapeStyle(Color.orange.opacity(colorScheme == .dark ? 0.15 : 0.08)) + case .contextTarget?: + return AnyShapeStyle(Color.secondary.opacity(colorScheme == .dark ? 0.12 : 0.06)) + case nil: + return AnyShapeStyle(.clear) + } + } + + var leadingAccentColor: Color? { + if isRebaseHoverTarget { + return .accentColor + } + return selectionAccent?.color + } + + var outlineState: DAGRowOutlineState? { + if isRebaseHoverTarget { + return .hoverTarget + } + if isRebaseArmed { + return .armed + } + return nil + } + + var isRebaseSource: Bool { + switch rebaseState { + case .sourceArmed, .sourceDragging: + true + default: + false + } + } + + var isRebaseArmed: Bool { + if case .sourceArmed = rebaseState { + return true + } + return false + } + + var isRebaseDragging: Bool { + if case .sourceDragging = rebaseState { + return true + } + return false + } + + var isRebaseCandidate: Bool { + switch rebaseState { + case .candidate, .hoverTarget: + true + default: + false + } + } + + var isRebaseHoverTarget: Bool { + if case .hoverTarget = rebaseState { + return true + } + return false + } + + var showsReturnHint: Bool { + if case .hoverTarget(let previewText) = rebaseState { + return previewText != nil + } + return false + } + + var dragTargetText: String? { + switch rebaseState { + case .hoverTarget(let previewText): + previewText ?? "Release to rebase here" + case .sourceArmed: + "Drag to choose a new parent" + default: + nil + } + } + + var scale: CGFloat { + isRebaseArmed ? 1.01 : 1 + } + + var opacity: Double { + isRebaseDragging ? 0.56 : 1 + } + + func wiggleAngle(at date: Date) -> Double { + guard case .sourceArmed(let armedAt) = rebaseState, + let armedAt, + date.timeIntervalSince(armedAt) >= 0.12 + else { return 0 } + return sin(date.timeIntervalSinceReferenceDate * 18) * 1.1 + } +} diff --git a/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift b/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift new file mode 100644 index 0000000..af119cb --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift @@ -0,0 +1,252 @@ +import AppKit +import JayJayCore +import SwiftUI + +enum DAGRebaseCoordinateSpace { + static let name = "dag-rebase" +} + +struct DAGRebaseRowFramePreferenceKey: PreferenceKey { + static let defaultValue: [String: CGRect] = [:] + + static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + +extension DAGView { + func rebaseGesture(for entry: GraphEntry, layout: DAGLayout) -> some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .named(DAGRebaseCoordinateSpace.name)) + .onChanged { value in + handleRebaseGestureChanged(entry: entry, layout: layout, value: value) + } + .onEnded { value in + handleRebaseGestureEnded(entry: entry, layout: layout, value: value) + } + } + + @ViewBuilder + var rebaseDragOverlay: some View { + if let rebaseDrag, rebaseDrag.phase == .dragging { + DAGRebaseGhost(label: rebaseDrag.sourceLabel) + .position(x: rebaseDrag.location.x + 68, y: rebaseDrag.location.y - 18) + .allowsHitTesting(false) + } + } + + func rebasePreviewText(for change: ChangeInfo) -> String? { + guard rebasePreviewTargetId == change.commitId, + let rebaseDrag + else { return nil } + return "Rebase \(rebaseDrag.sourceLabel) onto \(DAGRebaseGesturePolicy.displayLabel(for: change))?" + } + + private func handleRebaseGestureChanged( + entry: GraphEntry, + layout: DAGLayout, + value: DragGesture.Value + ) { + let action = DAGRebaseGesturePolicy.changeAction( + entryIsImmutable: entry.change.isImmutable, + sourceCommitId: entry.change.commitId, + rebaseDrag: rebaseDrag, + location: value.location + ) + + switch action { + case .ignore: + break + case .beginPress: + beginRebasePress(for: entry, layout: layout, location: value.location) + case .cancelPress: + cancelRebaseDrag() + case .beginDragging: + beginDraggingIfNeeded() + updateRebaseDrag(location: value.location) + case .updateDragging: + updateRebaseDrag(location: value.location) + } + } + + private func handleRebaseGestureEnded( + entry: GraphEntry, + layout: DAGLayout, + value: DragGesture.Value + ) { + let action = DAGRebaseGesturePolicy.endAction( + entryIsImmutable: entry.change.isImmutable, + sourceCommitId: entry.change.commitId, + rebaseDrag: rebaseDrag, + startLocation: value.startLocation, + location: value.location + ) + + switch action { + case .ignore: + break + case .select: + cancelRebaseDrag() + selectEntry(entry) + case .cancel: + cancelRebaseDrag() + case .confirmDrop: + updateRebaseDrag(location: value.location) + confirmRebaseDrop() + } + } + + private func beginRebasePress(for entry: GraphEntry, layout: DAGLayout, location: CGPoint) { + guard rebaseDrag?.sourceCommitId != entry.change.commitId, + let seedLocation = rebaseDragSeedLocation(for: entry, layout: layout) + else { return } + + activePane = .dag + rebaseArmTask?.cancel() + rebaseDrag = DAGRebaseDragState( + sourceCommitId: entry.change.commitId, + sourceChangeId: entry.change.changeId, + sourceRev: DAGRebaseGesturePolicy.revision(for: entry.change), + sourceLabel: DAGRebaseGesturePolicy.displayLabel(for: entry.change), + startLocation: location, + armedAt: nil, + phase: .pressing, + location: seedLocation, + hoveredCommitId: nil + ) + scheduleRebaseArm(for: entry) + } + + private func scheduleRebaseArm(for entry: GraphEntry) { + let sourceCommitId = entry.change.commitId + rebaseArmTask = Task { + try? await Task.sleep(for: .seconds(DAGRebaseGesturePolicy.armDuration)) + guard !Task.isCancelled else { return } + await MainActor.run { + guard var rebaseDrag, + rebaseDrag.sourceCommitId == sourceCommitId, + rebaseDrag.phase == .pressing + else { return } + rebaseDrag.phase = .armed + rebaseDrag.armedAt = .now + self.rebaseDrag = rebaseDrag + } + } + } + + private func beginDraggingIfNeeded() { + guard var rebaseDrag, rebaseDrag.phase != .dragging else { return } + rebaseDrag.phase = .dragging + self.rebaseDrag = rebaseDrag + } + + private func updateRebaseDrag(location: CGPoint) { + guard var rebaseDrag else { return } + let hoveredCommitId = rebaseRowFrames.first(where: { $0.value.contains(location) })?.key + let normalizedTarget = DAGRebaseGesturePolicy.normalizedTargetCommitId( + sourceCommitId: rebaseDrag.sourceCommitId, + hoveredCommitId: hoveredCommitId + ) + rebaseDrag.location = location + rebaseDrag.hoveredCommitId = normalizedTarget + self.rebaseDrag = rebaseDrag + updateRebasePreviewTarget(normalizedTarget) + } + + private func updateRebasePreviewTarget(_ commitId: String?) { + if commitId == rebasePreviewTargetId { + return + } + + rebasePreviewTask?.cancel() + rebasePreviewTask = nil + rebasePreviewTargetId = nil + + guard let commitId else { return } + + rebasePreviewTask = Task { + try? await Task.sleep(for: .milliseconds(DAGRebaseGesturePolicy.previewDelayMs)) + guard !Task.isCancelled else { return } + await MainActor.run { + guard rebaseDrag?.hoveredCommitId == commitId else { return } + rebasePreviewTargetId = commitId + } + } + } + + func confirmRebaseDrop() { + guard let request = DAGRebaseGesturePolicy.dropRequest( + rebaseDrag: rebaseDrag, + previewTargetCommitId: rebasePreviewTargetId, + hoveredCommitId: rebaseDrag?.hoveredCommitId, + entries: entries + ) else { + cancelRebaseDrag() + return + } + + cancelRebaseDrag() + + if let onRequestRebase { + onRequestRebase(request) + } else { + actions?.rebase(rev: request.sourceRev, dest: request.destRev) + } + } + + func cancelRebaseDrag() { + rebaseArmTask?.cancel() + rebaseArmTask = nil + rebasePreviewTask?.cancel() + rebasePreviewTask = nil + rebasePreviewTargetId = nil + rebaseDrag = nil + } + + private func selectEntry(_ entry: GraphEntry) { + activePane = .dag + NSApp.keyWindow?.makeFirstResponder(nil) + if NSEvent.modifierFlags.contains(.shift), + let sel = selectedId, sel != entry.change.changeId + { + actions?.compareWith(from: sel, to: entry.change.changeId) + } else { + actions?.select(changeId: entry.change.changeId) + } + } + + private func rebaseMovementDistance(for rebaseDrag: DAGRebaseDragState, to location: CGPoint) -> CGFloat { + DAGRebaseGesturePolicy.movementDistance(from: rebaseDrag.startLocation, to: location) + } + + private func rebaseDragSeedLocation(for entry: GraphEntry, layout: DAGLayout) -> CGPoint? { + guard let rowFrame = rebaseRowFrames[entry.change.commitId] else { return nil } + let lane = layout.lane(for: entry.change.commitId) + return CGPoint( + x: rowFrame.minX + dagRowLeadingPadding + CGFloat(lane) * laneWidth + laneWidth / 2 + 4, + y: rowFrame.midY + ) + } +} + +private struct DAGRebaseGhost: View { + let label: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app") + .jayjayFont(11, weight: .semibold) + Text(label) + .jayjayFont(11, weight: .medium) + .lineLimit(1) + } + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.regularMaterial, in: Capsule()) + .overlay( + Capsule() + .stroke(Color.accentColor.opacity(0.25), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 8, y: 4) + } +} diff --git a/shell/mac/Sources/JayJay/Repo/DAGView.swift b/shell/mac/Sources/JayJay/Repo/DAGView.swift index 72eed91..117c764 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGView.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGView.swift @@ -7,7 +7,9 @@ struct DAGView: View { let selectedId: String? let compareFromId: String? let actions: (any DAGActions)? + var onRequestRebase: ((DAGRebaseRequest) -> Void)? @Binding var activePane: ActivePane + var revealRequest: DAGRevealRequest? var onMoveBookmarkForward: ((String) -> Void)? var onPushBookmark: ((String) -> Void)? var onAbandon: ((String) -> Void)? @@ -15,11 +17,57 @@ struct DAGView: View { var onLoadMore: (() -> Void)? @State private var contextTargetId: String? + @State private var dagLayout: DAGLayout + @State private var dagLayoutEntries: [GraphEntry] + @State var rebaseRowFrames: [String: CGRect] = [:] + @State var rebaseDrag: DAGRebaseDragState? + @State var rebaseArmTask: Task? + @State var rebasePreviewTargetId: String? + @State var rebasePreviewTask: Task? @Environment(\.colorScheme) private var colorScheme + init( + entries: [GraphEntry], + selectedId: String?, + compareFromId: String?, + actions: (any DAGActions)?, + onRequestRebase: ((DAGRebaseRequest) -> Void)? = nil, + activePane: Binding, + revealRequest: DAGRevealRequest? = nil, + onMoveBookmarkForward: ((String) -> Void)? = nil, + onPushBookmark: ((String) -> Void)? = nil, + onAbandon: ((String) -> Void)? = nil, + onCreateBookmark: ((String) -> Void)? = nil, + onLoadMore: (() -> Void)? = nil + ) { + self.entries = entries + self.selectedId = selectedId + self.compareFromId = compareFromId + self.actions = actions + self.onRequestRebase = onRequestRebase + _activePane = activePane + self.revealRequest = revealRequest + self.onMoveBookmarkForward = onMoveBookmarkForward + self.onPushBookmark = onPushBookmark + self.onAbandon = onAbandon + self.onCreateBookmark = onCreateBookmark + self.onLoadMore = onLoadMore + _dagLayout = State(initialValue: DAGLayout(entries: entries)) + _dagLayoutEntries = State(initialValue: entries) + } + var body: some View { + let viewModel = DAGViewModel( + entries: entries, + selectedId: selectedId, + compareFromId: compareFromId, + contextTargetId: contextTargetId, + rebaseDrag: rebaseDrag, + colorScheme: colorScheme, + layout: currentLayout + ) Group { - if entries.isEmpty { + if viewModel.isEmpty { ContentUnavailableView( "No Changes Matched", systemImage: "line.3.horizontal.decrease.circle", @@ -27,40 +75,32 @@ struct DAGView: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - let layout = DAGLayout(entries: entries) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(Array(entries.enumerated()), id: \.element.change.commitId) { index, entry in DAGRow( - entry: entry, layout: layout, index: index, - isSelected: selectedId == entry.change.changeId, - isCompareSource: compareFromId == entry.change.changeId, - isContextTarget: contextTargetId == entry.change.changeId, - isLast: index == entries.count - 1, - colorScheme: colorScheme, + viewModel: viewModel.rowViewModel( + for: entry, + index: index, + previewText: rebasePreviewText(for: entry.change) + ), onMoveBookmarkForward: onMoveBookmarkForward, onPushBookmark: onPushBookmark ) - .contentShape(Rectangle()) - .onTapGesture { - activePane = .dag - NSApp.keyWindow?.makeFirstResponder(nil) - if NSEvent.modifierFlags.contains(.shift), - let sel = selectedId, sel != entry.change.changeId - { - actions?.compareWith(from: sel, to: entry.change.changeId) - } else { - actions?.select(changeId: entry.change.changeId) + .background( + GeometryReader { geo in + Color.clear.preference( + key: DAGRebaseRowFramePreferenceKey.self, + value: [entry.change.commitId: geo.frame(in: .named(DAGRebaseCoordinateSpace.name))] + ) } - } + ) + .id(entry.change.changeId) + .contentShape(Rectangle()) .onHover { hovering in // Track right-click target via hover (context menu shows on hovered item) - if hovering, let sel = selectedId, sel != entry.change.changeId { - contextTargetId = entry.change.changeId - } else if !hovering, contextTargetId == entry.change.changeId { - contextTargetId = nil - } + contextTargetId = viewModel.nextContextTargetId(hovering: hovering, entry: entry) } .contextMenu { let rev = entry.change.isDivergent @@ -80,9 +120,7 @@ struct DAGView: View { if let sel = selectedId, sel != entry.change.changeId { Divider() - let selEntry = entries.first { $0.change.changeId == sel } - let selRev = selEntry?.change.isDivergent == true - ? (selEntry?.change.commitId ?? sel) : sel + let selRev = viewModel.selectedRevision(for: sel) // Selection actions Button { actions?.compareWith(from: selRev, to: rev) } label: { Label("Compare with selected", systemImage: "arrow.left.arrow.right") @@ -144,6 +182,7 @@ struct DAGView: View { } } } + .simultaneousGesture(rebaseGesture(for: entry, layout: viewModel.layout)) } if let onLoadMore { Button { @@ -164,6 +203,7 @@ struct DAGView: View { } .padding(.vertical, 6) } + .coordinateSpace(name: DAGRebaseCoordinateSpace.name) .background( LinearGradient( colors: [Color.primary.opacity(colorScheme == .dark ? 0.03 : 0.015), .clear], @@ -171,12 +211,17 @@ struct DAGView: View { endPoint: .bottomTrailing ) ) - .onChange(of: selectedId) { _, newValue in - guard let newValue, - let entry = entries.first(where: { $0.change.changeId == newValue }) - else { return } - withAnimation(.easeOut(duration: 0.15)) { - proxy.scrollTo(entry.change.commitId, anchor: .center) + .overlay(alignment: .topLeading) { rebaseDragOverlay } + .onPreferenceChange(DAGRebaseRowFramePreferenceKey.self) { rebaseRowFrames = $0 } + .onChange(of: entries.map(\.change.commitId)) { _, _ in + if viewModel.shouldCancelRebaseDrag(for: rebaseDrag?.hoveredCommitId) { + cancelRebaseDrag() + } + } + .onChange(of: revealRequest?.id) { _, _ in + guard let changeId = revealRequest?.changeId else { return } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(changeId, anchor: .center) } } } @@ -190,41 +235,61 @@ struct DAGView: View { .frame(width: 0, height: 0) .allowsHitTesting(false) ) + .onChange(of: entries) { _, _ in + updateDagLayout() + } } private func handleKeyDown(_ event: NSEvent) -> Bool { + handleRebaseKeyDown(event) || handleSelectionKeyDown(event) + } + + private func moveSelection(by delta: Int) { + let viewModel = DAGViewModel( + entries: entries, + selectedId: selectedId, + compareFromId: compareFromId, + contextTargetId: contextTargetId, + rebaseDrag: rebaseDrag, + colorScheme: colorScheme, + layout: currentLayout + ) + guard let changeId = viewModel.selectedChangeId(afterMovingBy: delta) else { return } + actions?.select(changeId: changeId) + } + + private var currentLayout: DAGLayout { + dagLayoutEntries == entries ? dagLayout : DAGLayout(entries: entries) + } + + private func updateDagLayout() { + guard dagLayoutEntries != entries else { return } + dagLayout = DAGLayout(entries: entries) + dagLayoutEntries = entries + } + + private func handleRebaseKeyDown(_ event: NSEvent) -> Bool { + guard let rebaseDrag, rebaseDrag.phase != .pressing else { return false } switch event.keyCode { - case 125: moveSelection(by: 1) - return true // Down arrow - case 126: moveSelection(by: -1) - return true // Up arrow - default: break - } - let isCtrl = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .control - switch event.charactersIgnoringModifiers { - case "j": moveSelection(by: 1) - return true - case "k": moveSelection(by: -1) - return true - case "n" where isCtrl: moveSelection(by: 1) + case 53: + cancelRebaseDrag() return true - case "p" where isCtrl: moveSelection(by: -1) + case 36, 76: + confirmRebaseDrop() return true - default: return false + default: + return false } } - private func moveSelection(by delta: Int) { - guard !entries.isEmpty else { return } - let currentIdx: Int = if let selectedId, - let idx = entries.firstIndex(where: { $0.change.changeId == selectedId }) - { - idx - } else { - delta > 0 ? -1 : entries.count - } - let newIdx = max(0, min(entries.count - 1, currentIdx + delta)) - guard newIdx != currentIdx else { return } - actions?.select(changeId: entries[newIdx].change.changeId) + private func handleSelectionKeyDown(_ event: NSEvent) -> Bool { + let isCtrl = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .control + guard let delta = DAGViewModel.selectionDelta( + keyCode: event.keyCode, + charactersIgnoringModifiers: event.charactersIgnoringModifiers, + controlPressed: isCtrl + ) else { return false } + moveSelection(by: delta) + return true } } diff --git a/shell/mac/Sources/JayJay/Repo/DAGViewModel.swift b/shell/mac/Sources/JayJay/Repo/DAGViewModel.swift new file mode 100644 index 0000000..66a6b65 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/DAGViewModel.swift @@ -0,0 +1,116 @@ +import JayJayCore +import SwiftUI + +struct DAGViewModel { + private static let downArrowKeyCode: UInt16 = 125 + private static let upArrowKeyCode: UInt16 = 126 + + let entries: [GraphEntry] + let selectedId: String? + let compareFromId: String? + let contextTargetId: String? + let rebaseDrag: DAGRebaseDragState? + let colorScheme: ColorScheme + let layout: DAGLayout + + init( + entries: [GraphEntry], + selectedId: String?, + compareFromId: String?, + contextTargetId: String?, + rebaseDrag: DAGRebaseDragState?, + colorScheme: ColorScheme, + layout: DAGLayout + ) { + self.entries = entries + self.selectedId = selectedId + self.compareFromId = compareFromId + self.contextTargetId = contextTargetId + self.rebaseDrag = rebaseDrag + self.colorScheme = colorScheme + self.layout = layout + } + + var isEmpty: Bool { + entries.isEmpty + } + + func rowViewModel(for entry: GraphEntry, index: Int, previewText: String?) -> DAGRowViewModel { + DAGRowViewModel( + entry: entry, + layout: layout, + index: index, + selectedId: selectedId, + compareFromId: compareFromId, + contextTargetId: contextTargetId, + rebaseDrag: rebaseDrag, + rebasePreviewText: previewText, + colorScheme: colorScheme + ) + } + + func nextContextTargetId(hovering: Bool, entry: GraphEntry) -> String? { + if hovering, let selectedId, selectedId != entry.change.changeId { + return entry.change.changeId + } + if !hovering, contextTargetId == entry.change.changeId { + return nil + } + return contextTargetId + } + + func shouldCancelRebaseDrag(for hoveredCommitId: String?) -> Bool { + guard let hoveredCommitId else { return false } + return !entries.contains(where: { $0.change.commitId == hoveredCommitId }) + } + + func selectedChangeId(afterMovingBy delta: Int) -> String? { + guard !entries.isEmpty else { return nil } + let currentIdx: Int + if let selectedId, + let idx = entries.firstIndex(where: { $0.change.changeId == selectedId }) + { + currentIdx = idx + } else { + currentIdx = delta > 0 ? -1 : entries.count + } + let newIdx = max(0, min(entries.count - 1, currentIdx + delta)) + guard newIdx != currentIdx else { return nil } + return entries[newIdx].change.changeId + } + + func selectedRevision(for changeId: String) -> String { + guard let selectedEntry = entries.first(where: { $0.change.changeId == changeId }) else { + return changeId + } + return selectedEntry.change.isDivergent ? selectedEntry.change.commitId : changeId + } + + static func selectionDelta( + keyCode: UInt16, + charactersIgnoringModifiers: String?, + controlPressed: Bool + ) -> Int? { + switch keyCode { + case downArrowKeyCode: + return 1 + case upArrowKeyCode: + return -1 + default: + break + } + + switch charactersIgnoringModifiers { + case "j": + return 1 + case "k": + return -1 + case "n" where controlPressed: + return 1 + case "p" where controlPressed: + return -1 + default: + return nil + } + } +} diff --git a/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift b/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift index 6cd5035..66ddaff 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift @@ -59,7 +59,7 @@ extension RepoContentView { title: "Bookmark Manager", icon: "bookmark", category: "Repository" - ) { showBookmarkManager = true }) + ) { modal = .bookmarkManager }) items.append(CommandPaletteItem( title: "Clean Up Stale Bookmarks", icon: "bookmark.slash", @@ -99,8 +99,7 @@ extension RepoContentView { icon: "bookmark", category: "Change" ) { - bookmarkCreateRev = selection - bookmarkCreateName = "" + presentBookmarkCreate(rev: selection) }) } @@ -108,7 +107,7 @@ extension RepoContentView { title: "New Workspace", icon: "square.on.square", category: "Workspace" - ) { showWorkspaceCreate = true }) + ) { modal = .workspaceCreate }) for workspace in viewModel.workspaceList() where !workspace.isCurrent { items.append(CommandPaletteItem( title: "Switch to \(workspace.name)", diff --git a/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift b/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift new file mode 100644 index 0000000..cc373c0 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift @@ -0,0 +1,370 @@ +import JayJayCore +import SwiftUI + +private let sponsorPromptInterval = 20 + +extension RepoContentView { + var overlayState: RepoOverlayState? { + if viewModel.isLoading { + return .loading + } + if let toast { + return .toast(toast) + } + return nil + } + + var presentationOverlay: some View { + Group { + switch overlayState { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + case .toast(let toast): + RepoToastView( + toast: toast, + dismiss: dismissToast, + colorScheme: colorScheme + ) + .contentShape(Rectangle()) + .onTapGesture { dismissToast() } + .transition(.scale(scale: 0.9).combined(with: .opacity)) + case nil: + EmptyView() + } + } + } + + var alertState: RepoAlertState? { + if let error = viewModel.error { + return .error(error) + } + if let warning = viewModel.configWarning { + return .configWarning(warning) + } + return nil + } + + var isAlertPresented: Binding { + .init( + get: { alertState != nil }, + set: { isPresented in + guard !isPresented else { return } + viewModel.error = nil + viewModel.configWarning = nil + } + ) + } + + var alertTitle: String { + switch alertState { + case .error: + "Error" + case .configWarning: + "jj Configuration Incomplete" + case nil: + "" + } + } + + @ViewBuilder + func alertActions(for alert: RepoAlertState) -> some View { + switch alert { + case .error: + Button("OK") { viewModel.error = nil } + case .configWarning: + Button("Open Settings") { openSettings() } + Button("Dismiss", role: .cancel) { viewModel.configWarning = nil } + } + } + + func alertMessage(for alert: RepoAlertState) -> String { + switch alert { + case .error(let message), .configWarning(let message): + message + } + } + + @ViewBuilder + func modalView(for modal: RepoModalState) -> some View { + switch modal { + case .createBookmark(let rev): + bookmarkCreateSheet(rev: rev) + case .confirmAbandon(let rev): + abandonSheet(rev: rev) + case .confirmRebase(let request): + rebaseConfirmationSheet(request: request) + case .submoduleAttention: + submoduleAttentionSheet + case .undoLog: + UndoView( + entries: viewModel.opLogEntries, + onRestore: { opId in viewModel.opRestore(opId: opId) }, + onDismiss: { self.modal = nil } + ) + case .bookmarkManager: + BookmarkManagerView( + bookmarks: viewModel.bookmarks, + actions: viewModel, + repo: viewModel.repo, + onCleanUp: { viewModel.forgetStaleBookmarks() }, + onFilter: { bookmarkName in + self.modal = nil + revsetDraft = "ancestors(\(bookmarkName), 20) | trunk()" + applyRevset() + }, + onDismiss: { self.modal = nil } + ) + case .workspaceCreate: + workspaceCreateSheet + case .sponsorPrompt: + SponsorPromptView( + onDismiss: { self.modal = nil }, + onDontShowAgain: { + settings.sponsorDismissed = true + self.modal = nil + } + ) + } + } + + func handleSuccessActionSignalChange() { + settings.sponsorActionCount += 1 + if settings.sponsorActionCount >= settings.sponsorNextPromptCount, + !settings.sponsorDismissed, + modal == nil + { + settings.sponsorNextPromptCount = settings.sponsorActionCount + sponsorPromptInterval + modal = .sponsorPrompt + } + } + + func handleSubmoduleAttentionChange() { + if viewModel.submoduleAttentionItems.isEmpty { + if case .submoduleAttention = modal { + modal = nil + } + } else if modal == nil { + modal = .submoduleAttention + } + } + + func showUndo() { + viewModel.opLog() + modal = .undoLog + } + + func requestAbandon(_ rev: String) { + if settings.skipAbandonConfirmation { + viewModel.abandon(rev: rev) + } else { + modal = .confirmAbandon(rev: rev) + } + } + + func presentBookmarkCreate(rev: String) { + bookmarkCreateName = "" + modal = .createBookmark(rev: rev) + } + + func handleDAGRebase(_ request: DAGRebaseRequest) { + if settings.confirmDragRebase { + modal = .confirmRebase(request: request) + } else { + runDAGRebase(request) + } + } + + func showToast(_ message: String) { + showToast(message, action: nil) + } + + func showToast(_ message: String, action: RepoToastAction?) { + toastDismissTask?.cancel() + toast = RepoToastState(message: message, action: action) + let words = message.split(whereSeparator: \.isWhitespace).count + let seconds = min(max(Double(words) / 3.0, 2), 8) + toastDismissTask = Task { + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled else { return } + toast = nil + } + } + + private func dismissToast() { + toastDismissTask?.cancel() + toast = nil + } + + private func bookmarkCreateSheet(rev: String) -> some View { + SheetContainer( + title: "Create Bookmark", + subtitle: "On change: \(String(rev.prefix(12)))", + cancelLabel: "Cancel", + confirmLabel: "Create", + confirmDisabled: bookmarkCreateName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onCancel: { modal = nil }, + onConfirm: { submitBookmarkCreate(rev: rev) }, + content: { + TextField("Bookmark name", text: $bookmarkCreateName) + .textFieldStyle(.roundedBorder) + .jayjayFont(13, design: .monospaced) + .onSubmit { submitBookmarkCreate(rev: rev) } + } + ) + } + + private func abandonSheet(rev: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "trash.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.red) + Text("Abandon Change?") + .jayjayFont(16, weight: .semibold) + Text("This will remove the change and reparent its children.\nYou can undo this with jj op restore.") + .jayjayFont(13) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Toggle("Don't ask again", isOn: Binding( + get: { settings.skipAbandonConfirmation }, + set: { settings.skipAbandonConfirmation = $0 } + )) + .jayjayFont(12) + + HStack(spacing: 12) { + Button("Cancel") { modal = nil } + .keyboardShortcut(.cancelAction) + Button("Abandon") { + viewModel.abandon(rev: rev) + modal = nil + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .tint(.red) + } + } + .padding(24) + .frame(width: 340) + } + + private func rebaseConfirmationSheet(request: DAGRebaseRequest) -> some View { + SheetContainer( + title: "Rebase Change?", + subtitle: "\(String(request.sourceCommitId.prefix(12))) -> \(String(request.destCommitId.prefix(12)))", + cancelLabel: "Cancel", + confirmLabel: "Rebase", + onCancel: { modal = nil }, + onConfirm: { + modal = nil + runDAGRebase(request) + }, + content: { + VStack(alignment: .leading, spacing: 12) { + rebaseSummaryRow( + title: "Change", + value: request.sourceLabel, + detail: request.sourceChangeId + ) + Label("Will become a child of", systemImage: "arrow.down") + .jayjayFont(11) + .foregroundStyle(.secondary) + rebaseSummaryRow( + title: "New parent", + value: request.destLabel, + detail: request.destChangeId + ) + Toggle(isOn: Binding( + get: { settings.confirmDragRebase }, + set: { settings.confirmDragRebase = $0 } + )) { + Text("Confirm before drag-to-rebase") + .jayjayFont(12) + } + Text("Any conflicts will appear inline after the rebase.") + .jayjayFont(11) + .foregroundStyle(.secondary) + } + } + ) + .frame(width: 360) + } + + private var workspaceCreateSheet: some View { + SheetContainer( + title: "New Workspace", + subtitle: "Creates a new working copy in a sibling directory", + cancelLabel: "Cancel", + confirmLabel: "Create", + confirmDisabled: workspaceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onCancel: { modal = nil }, + onConfirm: { + let name = workspaceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + let parent = URL(fileURLWithPath: viewModel.repoPath).deletingLastPathComponent() + let dest = parent.appendingPathComponent(name).path + viewModel.workspaceAdd(dest: dest, name: name) + modal = nil + workspaceName = "" + windowManager.openRepo(dest) + }, + content: { + TextField("Workspace name", text: $workspaceName) + .textFieldStyle(.roundedBorder) + .jayjayFont(13, design: .monospaced) + } + ) + } + + private var submoduleAttentionSheet: some View { + SubmoduleAttentionSheet( + repoPath: viewModel.repoPath, + submoduleStatuses: viewModel.submoduleAttentionItems, + onClose: { + viewModel.submoduleAttentionItems = [] + viewModel.pendingCommitMessage = nil + modal = nil + }, + onAutoCommit: { await viewModel.commitWithSafeSubmoduleUpdates() } + ) + } + + private func submitBookmarkCreate(rev: String) { + let name = bookmarkCreateName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + viewModel.createBookmark(name: name, rev: rev) + modal = nil + } + + private func runDAGRebase(_ request: DAGRebaseRequest) { + viewModel.rebase( + request: request, + onSuccess: { repoViewModel, feedback in + let action = feedback.undoOperationId.map { operationId in + RepoToastAction(title: "Undo") { + repoViewModel.opRestore(opId: operationId) + } + } + showToast(feedback.message, action: action) + }, + onFailure: { _, message in + showToast(message) + } + ) + } + + private func rebaseSummaryRow(title: String, value: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .jayjayFont(11, weight: .semibold) + .foregroundStyle(.secondary) + Text(value) + .jayjayFont(13, weight: .medium) + .lineLimit(1) + Text(String(detail.prefix(12))) + .jayjayFont(10, design: .monospaced) + .foregroundStyle(.tertiary) + } + } +} diff --git a/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift b/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift new file mode 100644 index 0000000..bad2e8b --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift @@ -0,0 +1,52 @@ +import Foundation + +enum RepoModalState: Identifiable { + case createBookmark(rev: String) + case confirmAbandon(rev: String) + case confirmRebase(request: DAGRebaseRequest) + case submoduleAttention + case undoLog + case bookmarkManager + case workspaceCreate + case sponsorPrompt + + var id: String { + switch self { + case .createBookmark(let rev): "bookmark-\(rev)" + case .confirmAbandon(let rev): "abandon-\(rev)" + case .confirmRebase(let request): + "rebase-\(request.sourceCommitId)-\(request.destCommitId)" + case .submoduleAttention: "submodule-attention" + case .undoLog: "undo-log" + case .bookmarkManager: "bookmark-manager" + case .workspaceCreate: "workspace-create" + case .sponsorPrompt: "sponsor-prompt" + } + } +} + +enum RepoAlertState: Identifiable { + case error(String) + case configWarning(String) + + var id: String { + switch self { + case .error(let message): "error-\(message)" + case .configWarning(let message): "config-warning-\(message)" + } + } +} + +enum RepoOverlayState: Identifiable { + case loading + case toast(RepoToastState) + + var id: String { + switch self { + case .loading: + "loading" + case .toast(let state): + "toast-\(state.id)" + } + } +} diff --git a/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift b/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift index a3227eb..3df50c0 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift @@ -42,22 +42,24 @@ extension RepoContentView { selectedId: viewModel.selectedChangeId, compareFromId: viewModel.compareFromId, actions: viewModel, + onRequestRebase: { handleDAGRebase($0) }, activePane: $activePane, + revealRequest: dagRevealRequest, onMoveBookmarkForward: { viewModel.moveBookmarkForward(name: $0) }, onPushBookmark: { viewModel.gitPush(bookmark: $0) }, onAbandon: { requestAbandon($0) }, - onCreateBookmark: { rev in bookmarkCreateRev = rev - bookmarkCreateName = "" - }, + onCreateBookmark: { rev in presentBookmarkCreate(rev: rev) }, onLoadMore: viewModel.canLoadMore ? { viewModel.loadMore() } : nil ) - Divider() - CommitBox( - description: viewModel.workingCopyDescription, - onCommit: { await viewModel.commit(message: $0, manageSubmodules: settings.enableGitSubmoduleSupport) }, - onGenerateMessage: { await viewModel.generateCommitMessage() }, - aiProvider: viewModel.aiProvider - ) + if shouldShowCommitBox { + Divider() + CommitBox( + description: viewModel.workingCopyDescription, + onCommit: { await viewModel.commit(message: $0, manageSubmodules: settings.enableGitSubmoduleSupport) }, + onGenerateMessage: { await viewModel.generateCommitMessage() }, + aiProvider: viewModel.aiProvider + ) + } } } @@ -124,8 +126,25 @@ extension RepoContentView { return items } + private var shouldShowCommitBox: Bool { + viewModel.selectedChange?.info.isWorkingCopy == true + } + private var statusBarTrailingItems: [StatusBarItem] { - [.text(id: "changes", text: "\(viewModel.changes.count) changes")] + var items: [StatusBarItem] = [] + let conflictedCount = viewModel.changes.filter(\.hasConflict).count + if conflictedCount > 0 { + items.append(.action( + id: "conflicts", + icon: "exclamationmark.triangle.fill", + text: "\(conflictedCount) conflicted" + ) { + revsetDraft = "conflict()" + applyRevset() + }) + } + items.append(.text(id: "changes", text: "\(viewModel.changes.count) changes")) + return items } private var workspacePickerOptions: [StatusBarPickerOption] { diff --git a/shell/mac/Sources/JayJay/Repo/RepoToast.swift b/shell/mac/Sources/JayJay/Repo/RepoToast.swift new file mode 100644 index 0000000..2328723 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/RepoToast.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct RepoToastAction { + let title: String + let perform: () -> Void +} + +struct RepoToastState: Identifiable { + let id = UUID() + let message: String + var action: RepoToastAction? +} + +struct RepoToastView: View { + let toast: RepoToastState + let dismiss: () -> Void + let colorScheme: ColorScheme + + var body: some View { + HStack(spacing: 12) { + Text(toast.message) + .jayjayFont(13, weight: .medium) + .foregroundStyle(colorScheme == .dark ? .white : .black) + if let action = toast.action { + Button(action.title) { + dismiss() + action.perform() + } + .buttonStyle(.plain) + .jayjayFont(12, weight: .semibold) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(colorScheme == .dark ? 0.18 : 0.12), in: Capsule()) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(colorScheme == .dark ? Color.black.opacity(0.75) : Color.white.opacity(0.9)) + .shadow(color: .black.opacity(0.2), radius: 12, y: 6) + ) + } +} diff --git a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift index 85a8072..add0c8e 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift @@ -83,23 +83,24 @@ enum ActivePane { case dag, fileColumn } +struct DAGRevealRequest: Equatable, Identifiable { + let id = UUID() + let changeId: String +} + struct RepoContentView: View { @Bindable var viewModel: RepoViewModel @State var revsetDraft = "" @State var showRevsetFilter = false @State var sidebarWidth: CGFloat = 360 - @State var bookmarkCreateRev: String? @State var bookmarkCreateName = "" - @State var confirmAbandonRev: String? - @State var showUndoSheet = false - @State var showBookmarkManager = false - @State var showWorkspaceCreate = false + @State var modal: RepoModalState? @State var workspaceName = "" - @State var showSponsorPrompt = false @State var activePane: ActivePane = .dag @State var hasResetInitialFocus = false + @State var dagRevealRequest: DAGRevealRequest? let commandPanel = CommandPalettePanel() - @State var toastMessage: String? + @State var toast: RepoToastState? @State var toastDismissTask: Task? @State var menuCoordinator = RepoMenuHandler() @Environment(AppSettings.self) var settings @@ -117,8 +118,8 @@ struct RepoContentView: View { switch action { case .commandPalette: showCommandPalette() case .undo: showUndo() - case .bookmarkManager: showBookmarkManager = true - case .newWorkspace: showWorkspaceCreate = true + case .bookmarkManager: modal = .bookmarkManager + case .newWorkspace: modal = .workspaceCreate } } ActiveRepoTracker.shared.register( @@ -149,45 +150,27 @@ struct RepoContentView: View { } } .toolbar { toolbarContent } - .overlay { loadingOverlay } - .overlay { toastOverlay } - .animation(.easeOut(duration: 0.3), value: toastMessage) - .modifier(RepoAlertsModifier(viewModel: viewModel, openSettings: openSettings)) + .overlay { presentationOverlay } + .animation(.easeOut(duration: 0.3), value: toast?.id) + .alert(alertTitle, isPresented: isAlertPresented, presenting: alertState) { alert in + alertActions(for: alert) + } message: { alert in + Text(alertMessage(for: alert)) + } .onChange(of: viewModel.info) { _, msg in guard let msg, !msg.isEmpty else { return } showToast(msg) viewModel.info = nil } - .sheet(isPresented: bookmarkSheetPresented) { bookmarkCreateSheet } - .sheet(isPresented: abandonSheetPresented) { abandonSheet } - .sheet(isPresented: submoduleSheetPresented) { submoduleAttentionSheet } - .sheet(isPresented: $showUndoSheet) { - UndoView( - entries: viewModel.opLogEntries, - onRestore: { opId in viewModel.opRestore(opId: opId) }, - onDismiss: { showUndoSheet = false } - ) + .sheet(item: $modal) { modal in + modalView(for: modal) } - .sheet(isPresented: $showBookmarkManager) { - BookmarkManagerView( - bookmarks: viewModel.bookmarks, - actions: viewModel, - repo: viewModel.repo, - onCleanUp: { viewModel.forgetStaleBookmarks() }, - onFilter: { bookmarkName in - showBookmarkManager = false - revsetDraft = "ancestors(\(bookmarkName), 20) | trunk()" - applyRevset() - }, - onDismiss: { showBookmarkManager = false } - ) + .onChange(of: viewModel.successActionSignal) { + handleSuccessActionSignalChange() + } + .onChange(of: viewModel.submoduleAttentionItems.count) { + handleSubmoduleAttentionChange() } - .sheet(isPresented: $showWorkspaceCreate) { workspaceCreateSheet } - .modifier(SponsorPromptModifier( - signal: viewModel.successActionSignal, - settings: settings, - isPresented: $showSponsorPrompt - )) } private var contentLayout: some View { @@ -205,6 +188,7 @@ struct RepoContentView: View { diffStore: viewModel.diffStore, compareFromId: viewModel.compareFromId, onClearCompare: { viewModel.clearCompare() }, + onRevealChangeInDag: revealChangeInDAG, activePane: $activePane ) .frame(maxWidth: .infinity) @@ -215,188 +199,12 @@ struct RepoContentView: View { } } - private var loadingOverlay: some View { - Group { - if viewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.ultraThinMaterial) - } - } - } - - private var toastOverlay: some View { - Group { - if let toast = toastMessage { - Text(toast) - .jayjayFont(13, weight: .medium) - .foregroundStyle(colorScheme == .dark ? .white : .black) - .padding(.horizontal, 24) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(colorScheme == .dark ? Color.black.opacity(0.75) : Color.white.opacity(0.9)) - .shadow(color: .black.opacity(0.2), radius: 12, y: 6) - ) - .contentShape(Rectangle()) - .onTapGesture { - toastDismissTask?.cancel() - toastMessage = nil - } - .transition(.scale(scale: 0.9).combined(with: .opacity)) - } - } - } - - private var bookmarkSheetPresented: Binding { - .init( - get: { bookmarkCreateRev != nil }, - set: { if !$0 { bookmarkCreateRev = nil } } - ) - } - - private var abandonSheetPresented: Binding { - .init( - get: { confirmAbandonRev != nil }, - set: { if !$0 { confirmAbandonRev = nil } } - ) - } - - private var submoduleSheetPresented: Binding { - .init( - get: { !viewModel.submoduleAttentionItems.isEmpty }, - set: { - if !$0 { - viewModel.submoduleAttentionItems = [] - viewModel.pendingCommitMessage = nil - } - } - ) - } - - private var bookmarkCreateSheet: some View { - SheetContainer( - title: "Create Bookmark", - subtitle: "On change: \(String(bookmarkCreateRev?.prefix(12) ?? ""))", - cancelLabel: "Cancel", - confirmLabel: "Create", - confirmDisabled: bookmarkCreateName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - onCancel: { bookmarkCreateRev = nil }, - onConfirm: { submitBookmarkCreate() }, - content: { - TextField("Bookmark name", text: $bookmarkCreateName) - .textFieldStyle(.roundedBorder) - .jayjayFont(13, design: .monospaced) - .onSubmit { submitBookmarkCreate() } - } - ) - } - - private var abandonSheet: some View { - VStack(spacing: 16) { - Image(systemName: "trash.circle.fill") - .font(.system(size: 36)) - .foregroundStyle(.red) - Text("Abandon Change?") - .jayjayFont(16, weight: .semibold) - Text("This will remove the change and reparent its children.\nYou can undo this with jj op restore.") - .jayjayFont(13) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Toggle("Don't ask again", isOn: Binding( - get: { settings.skipAbandonConfirmation }, - set: { settings.skipAbandonConfirmation = $0 } - )) - .jayjayFont(12) - - HStack(spacing: 12) { - Button("Cancel") { confirmAbandonRev = nil } - .keyboardShortcut(.cancelAction) - Button("Abandon") { - if let rev = confirmAbandonRev { - viewModel.abandon(rev: rev) - confirmAbandonRev = nil - } - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .tint(.red) - } - } - .padding(24) - .frame(width: 340) - } - - private var workspaceCreateSheet: some View { - SheetContainer( - title: "New Workspace", - subtitle: "Creates a new working copy in a sibling directory", - cancelLabel: "Cancel", - confirmLabel: "Create", - confirmDisabled: workspaceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - onCancel: { showWorkspaceCreate = false }, - onConfirm: { - let name = workspaceName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { return } - let parent = URL(fileURLWithPath: viewModel.repoPath).deletingLastPathComponent() - let dest = parent.appendingPathComponent(name).path - viewModel.workspaceAdd(dest: dest, name: name) - showWorkspaceCreate = false - workspaceName = "" - windowManager.openRepo(dest) - }, - content: { - TextField("Workspace name", text: $workspaceName) - .textFieldStyle(.roundedBorder) - .jayjayFont(13, design: .monospaced) - } - ) - } - - private var submoduleAttentionSheet: some View { - SubmoduleAttentionSheet( - repoPath: viewModel.repoPath, - submoduleStatuses: viewModel.submoduleAttentionItems, - onClose: { - viewModel.submoduleAttentionItems = [] - viewModel.pendingCommitMessage = nil - }, - onAutoCommit: { await viewModel.commitWithSafeSubmoduleUpdates() } - ) - } - - func showUndo() { - viewModel.opLog() - showUndoSheet = true - } - - func requestAbandon(_ rev: String) { - if settings.skipAbandonConfirmation { - viewModel.abandon(rev: rev) - } else { - confirmAbandonRev = rev - } - } - - private func submitBookmarkCreate() { - let name = bookmarkCreateName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, let rev = bookmarkCreateRev else { return } - viewModel.createBookmark(name: name, rev: rev) - bookmarkCreateRev = nil + private func revealChangeInDAG(_ changeId: String) { + activePane = .dag + dagRevealRequest = DAGRevealRequest(changeId: changeId) + viewModel.select(changeId: changeId) } - func showToast(_ message: String) { - toastDismissTask?.cancel() - toastMessage = message - let words = message.split(whereSeparator: \.isWhitespace).count - let seconds = min(max(Double(words) / 3.0, 2), 8) - toastDismissTask = Task { - try? await Task.sleep(for: .seconds(seconds)) - guard !Task.isCancelled else { return } - toastMessage = nil - } - } } @MainActor @@ -423,33 +231,3 @@ final class RepoMenuHandler: RepositoryMenuHandler { onAction?(.newWorkspace) } } - -private struct RepoAlertsModifier: ViewModifier { - @Bindable var viewModel: RepoViewModel - let openSettings: OpenSettingsAction - - func body(content: Content) -> some View { - content - .alert("Error", isPresented: errorBinding) { - Button("OK") { viewModel.error = nil } - } message: { Text(viewModel.error ?? "") } - .alert("jj Configuration Incomplete", isPresented: configWarningBinding) { - Button("Open Settings") { openSettings() } - Button("Dismiss", role: .cancel) { viewModel.configWarning = nil } - } message: { Text(viewModel.configWarning ?? "") } - } - - private var errorBinding: Binding { - .init( - get: { viewModel.error != nil }, - set: { if !$0 { viewModel.error = nil } } - ) - } - - private var configWarningBinding: Binding { - .init( - get: { viewModel.configWarning != nil }, - set: { if !$0 { viewModel.configWarning = nil } } - ) - } -} diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift new file mode 100644 index 0000000..d61d54a --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift @@ -0,0 +1,96 @@ +import Foundation +import JayJayCore + +struct RepoRebaseFeedback { + let message: String + let undoOperationId: String? +} + +private struct RepoRebaseRefreshResult { + let graphEntries: [GraphEntry] + let bookmarks: [BookmarkInfo] + let workspaces: [WorkspaceInfo] + let selectedChange: ChangeDetail? + let workingCopyDescription: String + let hadConflicts: Bool + let undoOperationId: String? +} + +extension RepoViewModel { + func rebase( + request: DAGRebaseRequest, + onSuccess: @escaping @MainActor (RepoViewModel, RepoRebaseFeedback) -> Void, + onFailure: @escaping @MainActor (RepoViewModel, String) -> Void = { viewModel, message in + viewModel.error = message + } + ) { + lastInternalMutationAt = Date() + isRefreshingInFlight = true + error = nil + + runRepoTask( + { [requestedRevset = revset] repo in + let undoOperationId = try repo.opLog().first(where: { $0.isCurrent })?.id + try repo.rebase(rev: request.sourceRev, dest: request.destRev) + try repo.refreshWorkingCopy() + + let graphEntries = try repo.logGraph(revset: requestedRevset) + let log = graphEntries.map(\.change) + let bookmarks = try repo.listBookmarks() + let workspaces = (try? repo.workspaceList()) ?? [] + let selectedChange = try Self.loadSelectedDetail( + repo: repo, + log: log, + preferredRev: request.sourceChangeId + ) + let workingCopyDescription = log.first(where: { $0.isWorkingCopy })?.description ?? "" + let hadConflicts = graphEntries.contains(where: { + $0.change.changeId == request.sourceChangeId && $0.change.hasConflict + }) + + return RepoRebaseRefreshResult( + graphEntries: graphEntries, + bookmarks: bookmarks, + workspaces: workspaces, + selectedChange: selectedChange, + workingCopyDescription: workingCopyDescription, + hadConflicts: hadConflicts, + undoOperationId: undoOperationId + ) + }, + onSuccess: { viewModel, result in + viewModel.successActionSignal += 1 + viewModel.graphEntries = result.graphEntries + viewModel.bookmarks = result.bookmarks + viewModel.workspaces = result.workspaces + viewModel.selectedChange = result.selectedChange + viewModel.selectedChangeId = result.selectedChange?.info.changeId + viewModel.workingCopyDescription = result.workingCopyDescription + viewModel.isLoading = false + viewModel.isRefreshingInFlight = false + viewModel.hasWorkingCopyChanges = false + viewModel.canLoadMore = Self.canLoadMore( + revset: viewModel.revset, + loadedCount: result.graphEntries.count + ) + viewModel.fetchPrInfo(bookmarks: result.selectedChange?.info.bookmarks ?? []) + + onSuccess(viewModel, RepoRebaseFeedback( + message: Self.rebaseMessage(for: request, hadConflicts: result.hadConflicts), + undoOperationId: result.undoOperationId + )) + }, + onFailure: { viewModel, error in + viewModel.isLoading = false + viewModel.isRefreshingInFlight = false + onFailure(viewModel, error.friendlyDescription) + } + ) + } + + private static func rebaseMessage(for request: DAGRebaseRequest, hadConflicts: Bool) -> String { + let base = "Rebased \(request.sourceLabel) onto \(request.destLabel)." + guard hadConflicts else { return base } + return "\(base) Conflicts need resolution." + } +} diff --git a/shell/mac/Sources/JayJay/Settings/SettingsView.swift b/shell/mac/Sources/JayJay/Settings/SettingsView.swift index c00ba19..1df9d3e 100644 --- a/shell/mac/Sources/JayJay/Settings/SettingsView.swift +++ b/shell/mac/Sources/JayJay/Settings/SettingsView.swift @@ -119,6 +119,12 @@ struct SettingsView: View { )) { settingsLabel("Skip abandon confirmation", icon: "trash") } + Toggle(isOn: Binding( + get: { settings.confirmDragRebase }, + set: { settings.confirmDragRebase = $0 } + )) { + settingsLabel("Confirm drag-to-rebase", icon: "arrow.up.forward.app") + } } } .formStyle(.grouped) diff --git a/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift b/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift new file mode 100644 index 0000000..ac559e0 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift @@ -0,0 +1,184 @@ +@testable import JayJay +import JayJayCore +import XCTest + +final class DAGRebaseGesturePolicyTests: XCTestCase { + func testBeginsPress() { + let action = DAGRebaseGesturePolicy.changeAction( + entryIsImmutable: false, + sourceCommitId: "source", + rebaseDrag: nil, + location: CGPoint(x: 10, y: 20) + ) + + XCTAssertEqual(action, .beginPress) + } + + func testCancelsPressOnLargeMove() { + let state = makeDragState(phase: .pressing, startLocation: .zero) + + let action = DAGRebaseGesturePolicy.changeAction( + entryIsImmutable: false, + sourceCommitId: "source", + rebaseDrag: state, + location: CGPoint(x: 20, y: 0) + ) + + XCTAssertEqual(action, .cancelPress) + } + + func testStartsDragAfterArm() { + let state = makeDragState(phase: .armed, startLocation: .zero) + + let action = DAGRebaseGesturePolicy.changeAction( + entryIsImmutable: false, + sourceCommitId: "source", + rebaseDrag: state, + location: CGPoint(x: 3, y: 0) + ) + + XCTAssertEqual(action, .beginDragging) + } + + func testSelectsImmutableCommit() { + let action = DAGRebaseGesturePolicy.endAction( + entryIsImmutable: true, + sourceCommitId: "immutable", + rebaseDrag: nil, + startLocation: .zero, + location: CGPoint(x: 2, y: 1) + ) + + XCTAssertEqual(action, .select) + } + + func testIgnoresImmutableCommitAfterDrag() { + let action = DAGRebaseGesturePolicy.endAction( + entryIsImmutable: true, + sourceCommitId: "immutable", + rebaseDrag: nil, + startLocation: .zero, + location: CGPoint(x: 20, y: 0) + ) + + XCTAssertEqual(action, .ignore) + } + + func testSelectsMutableCommitBeforeArm() { + let action = DAGRebaseGesturePolicy.endAction( + entryIsImmutable: false, + sourceCommitId: "source", + rebaseDrag: makeDragState(phase: .pressing), + startLocation: .zero, + location: CGPoint(x: 1, y: 1) + ) + + XCTAssertEqual(action, .select) + } + + func testConfirmsDrop() { + let action = DAGRebaseGesturePolicy.endAction( + entryIsImmutable: false, + sourceCommitId: "source", + rebaseDrag: makeDragState(phase: .dragging), + startLocation: .zero, + location: CGPoint(x: 12, y: 4) + ) + + XCTAssertEqual(action, .confirmDrop) + } + + func testAllowsAncestorTarget() { + let ancestor = makeEntry( + changeId: "base-change", + commitId: "base-commit", + description: "main", + isImmutable: false + ) + let source = makeEntry( + changeId: "source-change", + commitId: "source-commit", + description: "feat-x", + isImmutable: false, + parents: ["base-commit"] + ) + + let request = DAGRebaseGesturePolicy.dropRequest( + rebaseDrag: makeDragState(phase: .dragging), + previewTargetCommitId: nil, + hoveredCommitId: "base-commit", + entries: [source, ancestor] + ) + + XCTAssertEqual(request?.sourceChangeId, "change") + XCTAssertEqual(request?.destCommitId, "base-commit") + XCTAssertEqual(request?.destRev, "base-change") + XCTAssertEqual(request?.destLabel, "main") + } + + func testAllowsImmutableTarget() { + let immutableTarget = makeEntry( + changeId: "target-change", + commitId: "target-commit", + description: "", + isImmutable: true, + bookmarks: ["main"] + ) + + let request = DAGRebaseGesturePolicy.dropRequest( + rebaseDrag: makeDragState(phase: .dragging), + previewTargetCommitId: nil, + hoveredCommitId: "target-commit", + entries: [immutableTarget] + ) + + XCTAssertEqual(request?.destCommitId, "target-commit") + XCTAssertEqual(request?.destRev, "target-change") + XCTAssertEqual(request?.destLabel, "main") + } + + private func makeDragState( + phase: DAGRebasePhase, + startLocation: CGPoint = .zero + ) -> DAGRebaseDragState { + DAGRebaseDragState( + sourceCommitId: "source", + sourceChangeId: "change", + sourceRev: "change", + sourceLabel: "feat-x", + startLocation: startLocation, + armedAt: phase == .pressing ? nil : Date(timeIntervalSinceReferenceDate: 10), + phase: phase, + location: startLocation, + hoveredCommitId: nil + ) + } + + private func makeEntry( + changeId: String, + commitId: String, + description: String, + isImmutable: Bool, + parents: [String] = [], + bookmarks: [String] = [] + ) -> GraphEntry { + GraphEntry( + change: ChangeInfo( + changeId: changeId, + commitId: commitId, + description: description, + author: "Tester", + email: "tester@example.com", + timestampMillis: 0, + parents: parents, + bookmarks: bookmarks, + isWorkingCopy: false, + hasConflict: false, + isEmpty: false, + isImmutable: isImmutable, + isDivergent: false + ), + edges: [] + ) + } +} diff --git a/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift b/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift new file mode 100644 index 0000000..d8178bc --- /dev/null +++ b/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift @@ -0,0 +1,164 @@ +@testable import JayJay +import JayJayCore +import SwiftUI +import XCTest + +final class DAGRowViewModelTests: XCTestCase { + func testPressingSourceHidesDragAffordances() { + let entry = makeEntry( + changeId: "source-change", + commitId: "source-commit", + description: "feat-x", + isImmutable: false + ) + + let viewModel = DAGRowViewModel( + entry: entry, + layout: DAGLayout(entries: [entry]), + index: 0, + selectedId: nil, + compareFromId: nil, + contextTargetId: nil, + rebaseDrag: makeDragState(sourceCommitId: "source-commit", phase: .pressing), + rebasePreviewText: nil, + colorScheme: .light + ) + + XCTAssertFalse(viewModel.isRebaseSource) + XCTAssertFalse(viewModel.isRebaseArmed) + XCTAssertNil(viewModel.dragTargetText) + XCTAssertEqual(viewModel.wiggleAngle(at: Date()), 0) + } + + func testArmedSourceShowsDragAffordances() { + let entry = makeEntry( + changeId: "source-change", + commitId: "source-commit", + description: "feat-x", + isImmutable: false + ) + let armedAt = Date(timeIntervalSinceReferenceDate: 10) + + let viewModel = DAGRowViewModel( + entry: entry, + layout: DAGLayout(entries: [entry]), + index: 0, + selectedId: nil, + compareFromId: nil, + contextTargetId: nil, + rebaseDrag: makeDragState( + sourceCommitId: "source-commit", + phase: .armed, + armedAt: armedAt + ), + rebasePreviewText: nil, + colorScheme: .light + ) + + XCTAssertTrue(viewModel.isRebaseSource) + XCTAssertTrue(viewModel.isRebaseArmed) + XCTAssertEqual(viewModel.dragTargetText, "Drag to choose a new parent") + XCTAssertNotEqual(viewModel.wiggleAngle(at: armedAt.addingTimeInterval(0.2)), 0) + } + + func testHoverTargetShowsPreview() { + let source = makeEntry( + changeId: "source-change", + commitId: "source-commit", + description: "feat-x", + isImmutable: false + ) + let target = makeEntry( + changeId: "target-change", + commitId: "target-commit", + description: "main update", + isImmutable: true + ) + + let viewModel = DAGRowViewModel( + entry: target, + layout: DAGLayout(entries: [source, target]), + index: 1, + selectedId: nil, + compareFromId: nil, + contextTargetId: nil, + rebaseDrag: makeDragState(sourceCommitId: "source-commit", phase: .dragging), + rebasePreviewText: "Rebase feat-x onto main?", + colorScheme: .dark + ) + + XCTAssertTrue(viewModel.isRebaseCandidate) + XCTAssertTrue(viewModel.isRebaseHoverTarget) + XCTAssertEqual(viewModel.dragTargetText, "Rebase feat-x onto main?") + XCTAssertTrue(viewModel.showsReturnHint) + } + + func testSelectedRowKeepsSelectionAccent() { + let entry = makeEntry( + changeId: "selected-change", + commitId: "selected-commit", + description: "feat-x", + isImmutable: false + ) + + let viewModel = DAGRowViewModel( + entry: entry, + layout: DAGLayout(entries: [entry]), + index: 0, + selectedId: "selected-change", + compareFromId: nil, + contextTargetId: nil, + rebaseDrag: nil, + rebasePreviewText: nil, + colorScheme: .light + ) + + XCTAssertEqual(viewModel.selectionAccent, .selected) + XCTAssertEqual(viewModel.leadingAccentColor, .accentColor) + XCTAssertNil(viewModel.dragTargetText) + } + + private func makeEntry( + changeId: String, + commitId: String, + description: String, + isImmutable: Bool + ) -> GraphEntry { + GraphEntry( + change: ChangeInfo( + changeId: changeId, + commitId: commitId, + description: description, + author: "Tester", + email: "tester@example.com", + timestampMillis: 0, + parents: [], + bookmarks: [], + isWorkingCopy: false, + hasConflict: false, + isEmpty: false, + isImmutable: isImmutable, + isDivergent: false + ), + edges: [] + ) + } + + private func makeDragState( + sourceCommitId: String, + phase: DAGRebasePhase, + armedAt: Date? = nil + ) -> DAGRebaseDragState { + DAGRebaseDragState( + sourceCommitId: sourceCommitId, + sourceChangeId: "source-change", + sourceRev: "source-change", + sourceLabel: "feat-x", + startLocation: .zero, + armedAt: armedAt, + phase: phase, + location: .zero, + hoveredCommitId: "target-commit" + ) + } +} diff --git a/shell/mac/Tests/JayJayTests/DAGViewModelTests.swift b/shell/mac/Tests/JayJayTests/DAGViewModelTests.swift new file mode 100644 index 0000000..668500c --- /dev/null +++ b/shell/mac/Tests/JayJayTests/DAGViewModelTests.swift @@ -0,0 +1,106 @@ +@testable import JayJay +import JayJayCore +import SwiftUI +import XCTest + +final class DAGViewModelTests: XCTestCase { + func testTracksHoveredContextTarget() { + let entry = makeEntry(changeId: "hovered", commitId: "hovered-commit", isDivergent: false) + let viewModel = makeViewModel(entries: [entry], selectedId: "selected", contextTargetId: nil) + + XCTAssertEqual(viewModel.nextContextTargetId(hovering: true, entry: entry), "hovered") + } + + func testClearsHoveredContextTarget() { + let entry = makeEntry(changeId: "hovered", commitId: "hovered-commit", isDivergent: false) + let viewModel = makeViewModel(entries: [entry], selectedId: "selected", contextTargetId: "hovered") + + XCTAssertNil(viewModel.nextContextTargetId(hovering: false, entry: entry)) + } + + func testCancelsMissingHoverTarget() { + let entry = makeEntry(changeId: "present", commitId: "present-commit", isDivergent: false) + let viewModel = makeViewModel(entries: [entry], selectedId: nil, contextTargetId: nil) + + XCTAssertTrue(viewModel.shouldCancelRebaseDrag(for: "missing-commit")) + XCTAssertFalse(viewModel.shouldCancelRebaseDrag(for: "present-commit")) + XCTAssertFalse(viewModel.shouldCancelRebaseDrag(for: nil)) + } + + func testMovesSelectionForwardAndBack() { + let first = makeEntry(changeId: "first", commitId: "first-commit", isDivergent: false) + let second = makeEntry(changeId: "second", commitId: "second-commit", isDivergent: false) + let viewModel = makeViewModel(entries: [first, second], selectedId: "first", contextTargetId: nil) + + XCTAssertEqual(viewModel.selectedChangeId(afterMovingBy: 1), "second") + XCTAssertNil(viewModel.selectedChangeId(afterMovingBy: -1)) + } + + func testUsesListEndsWithoutSelection() { + let first = makeEntry(changeId: "first", commitId: "first-commit", isDivergent: false) + let second = makeEntry(changeId: "second", commitId: "second-commit", isDivergent: false) + let viewModel = makeViewModel(entries: [first, second], selectedId: nil, contextTargetId: nil) + + XCTAssertEqual(viewModel.selectedChangeId(afterMovingBy: 1), "first") + XCTAssertEqual(viewModel.selectedChangeId(afterMovingBy: -1), "second") + } + + func testUsesCommitIdForDivergentSelection() { + let entry = makeEntry(changeId: "change", commitId: "commit", isDivergent: true) + let viewModel = makeViewModel(entries: [entry], selectedId: nil, contextTargetId: nil) + + XCTAssertEqual(viewModel.selectedRevision(for: "change"), "commit") + } + + func testUsesJKNavigation() { + XCTAssertEqual(DAGViewModel.selectionDelta(keyCode: 0, charactersIgnoringModifiers: "j", controlPressed: false), 1) + XCTAssertEqual(DAGViewModel.selectionDelta(keyCode: 0, charactersIgnoringModifiers: "k", controlPressed: false), -1) + } + + func testUsesCtrlNPNavigation() { + XCTAssertEqual(DAGViewModel.selectionDelta(keyCode: 0, charactersIgnoringModifiers: "n", controlPressed: true), 1) + XCTAssertEqual(DAGViewModel.selectionDelta(keyCode: 0, charactersIgnoringModifiers: "p", controlPressed: true), -1) + } + + func testIgnoresPlainNPNavigation() { + XCTAssertNil(DAGViewModel.selectionDelta(keyCode: 0, charactersIgnoringModifiers: "n", controlPressed: false)) + XCTAssertNil(DAGViewModel.selectionDelta(keyCode: 0, charactersIgnoringModifiers: "p", controlPressed: false)) + } + + private func makeViewModel( + entries: [GraphEntry], + selectedId: String?, + contextTargetId: String? + ) -> DAGViewModel { + DAGViewModel( + entries: entries, + selectedId: selectedId, + compareFromId: nil, + contextTargetId: contextTargetId, + rebaseDrag: nil, + colorScheme: .light, + layout: DAGLayout(entries: entries) + ) + } + + private func makeEntry(changeId: String, commitId: String, isDivergent: Bool) -> GraphEntry { + GraphEntry( + change: ChangeInfo( + changeId: changeId, + commitId: commitId, + description: "entry", + author: "Tester", + email: "tester@example.com", + timestampMillis: 0, + parents: [], + bookmarks: [], + isWorkingCopy: false, + hasConflict: false, + isEmpty: false, + isImmutable: false, + isDivergent: isDivergent + ), + edges: [] + ) + } +} diff --git a/shell/mac/project.yml b/shell/mac/project.yml index e28c6f5..2e40c9e 100644 --- a/shell/mac/project.yml +++ b/shell/mac/project.yml @@ -19,6 +19,11 @@ schemes: build: targets: JayJay: all + test: + config: Debug + gatherCoverageData: false + targets: + - JayJayTests run: config: Debug @@ -41,8 +46,8 @@ targets: base: ASSETCATALOG_COMPILER_APPICON_NAME: app PRODUCT_BUNDLE_IDENTIFIER: dev.hewig.jayjay - MARKETING_VERSION: "0.2.11" - CURRENT_PROJECT_VERSION: 15 + MARKETING_VERSION: "0.2.12" + CURRENT_PROJECT_VERSION: 16 GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_CFBundleDisplayName: JayJay INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools @@ -58,3 +63,15 @@ targets: SUPublicEDKey: UOMJSZnts1cRolglWcFAcibe9T9dD9flzz7fAQ3jmoQ= SUEnableAutomaticChecks: true SUAutomaticallyUpdate: false + + JayJayTests: + type: bundle.unit-test + platform: macOS + sources: + - Tests/JayJayTests + hostApplication: JayJay + dependencies: + - target: JayJay + settings: + base: + GENERATE_INFOPLIST_FILE: YES