From 9044e0d5e49782aec5a89505014576fbe446e498 Mon Sep 17 00:00:00 2001
From: hewigovens <360470+hewigovens@users.noreply.github.com>
Date: Mon, 13 Apr 2026 23:09:15 +0900
Subject: [PATCH] Polish drag-to-rebase and add macOS tests
---
.github/workflows/ci.yml | 3 +
AGENTS.md | 13 +-
Cargo.lock | 2 +-
crates/jayjay-cli/Cargo.toml | 2 +-
justfile | 4 +
releases/0.2.12.html | 19 +
shell/justfile | 15 +-
.../JayJay/App/Config/AppSettings.swift | 6 +
shell/mac/Sources/JayJay/App/JayJayApp.swift | 15 +-
.../Sources/JayJay/Detail/DetailView.swift | 15 +-
shell/mac/Sources/JayJay/Repo/DAGLayout.swift | 3 +
.../JayJay/Repo/DAGRebaseGesturePolicy.swift | 122 ++++++
.../Sources/JayJay/Repo/DAGRebaseModels.swift | 30 ++
shell/mac/Sources/JayJay/Repo/DAGRow.swift | 154 ++++++--
.../Sources/JayJay/Repo/DAGRowViewModel.swift | 213 ++++++++++
.../JayJay/Repo/DAGView+RebaseDrag.swift | 252 ++++++++++++
shell/mac/Sources/JayJay/Repo/DAGView.swift | 185 ++++++---
.../Sources/JayJay/Repo/DAGViewModel.swift | 116 ++++++
.../Repo/RepoContentView+CommandPalette.swift | 7 +-
.../Repo/RepoContentView+Presentation.swift | 370 ++++++++++++++++++
.../JayJay/Repo/RepoPresentation.swift | 52 +++
.../mac/Sources/JayJay/Repo/RepoSidebar.swift | 41 +-
shell/mac/Sources/JayJay/Repo/RepoToast.swift | 45 +++
.../mac/Sources/JayJay/Repo/RepoWindow.swift | 280 ++-----------
.../Actions/RepoViewModel+Rebase.swift | 96 +++++
.../JayJay/Settings/SettingsView.swift | 6 +
.../DAGRebaseGesturePolicyTests.swift | 184 +++++++++
.../JayJayTests/DAGRowViewModelTests.swift | 164 ++++++++
.../Tests/JayJayTests/DAGViewModelTests.swift | 106 +++++
shell/mac/project.yml | 21 +-
30 files changed, 2165 insertions(+), 376 deletions(-)
create mode 100644 releases/0.2.12.html
create mode 100644 shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/DAGViewModel.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/RepoPresentation.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/RepoToast.swift
create mode 100644 shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift
create mode 100644 shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift
create mode 100644 shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift
create mode 100644 shell/mac/Tests/JayJayTests/DAGViewModelTests.swift
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
+
+ - Drag a change directly onto another change to run
jj rebase without looking up change IDs
+ - Hold-to-arm interaction reduces accidental drags and keeps single-click selection intact
+ - Drag hover now shows a clear target outline, node ring, and inline rebase preview
+ - Successful drag rebases show a toast with inline conflict follow-up and a one-click Undo action
+
+DAG interaction polish
+
+ - Clicking a change no longer recenters or scroll-jumps the DAG unexpectedly
+ - The commit box is only shown for the working copy
+ - The Dock menu now includes
Open Repository… for faster access
+
+Internal cleanup
+
+ - Repo-level alerts, sheets, HUDs, and toasts now go through a consolidated presentation model
+ DAGRow and DAGView logic moved into focused view models to keep the UI code smaller and easier to test
+ - Added macOS app tests for drag-rebase gesture policy and DAG row/view-model regressions, and wired them into CI via
just test-app
+
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