Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,6 @@ jobs:

- name: Build app
run: just build

- name: Test app
run: just test-app
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/jayjay-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -23,6 +24,9 @@ list:
test:
cargo test --workspace

test-app:
just shell::test

build:
just shell::build

Expand Down
19 changes: 19 additions & 0 deletions releases/0.2.12.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<h3>Drag-to-rebase in the DAG</h3>
<ul>
<li>Drag a change directly onto another change to run <code>jj rebase</code> without looking up change IDs</li>
<li>Hold-to-arm interaction reduces accidental drags and keeps single-click selection intact</li>
<li>Drag hover now shows a clear target outline, node ring, and inline rebase preview</li>
<li>Successful drag rebases show a toast with inline conflict follow-up and a one-click Undo action</li>
</ul>
<h3>DAG interaction polish</h3>
<ul>
<li>Clicking a change no longer recenters or scroll-jumps the DAG unexpectedly</li>
<li>The commit box is only shown for the working copy</li>
<li>The Dock menu now includes <code>Open Repository…</code> for faster access</li>
</ul>
<h3>Internal cleanup</h3>
<ul>
<li>Repo-level alerts, sheets, HUDs, and toasts now go through a consolidated presentation model</li>
<li><code>DAGRow</code> and <code>DAGView</code> logic moved into focused view models to keep the UI code smaller and easier to test</li>
<li>Added macOS app tests for drag-rebase gesture policy and DAG row/view-model regressions, and wired them into CI via <code>just test-app</code></li>
</ul>
15 changes: 13 additions & 2 deletions shell/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions shell/mac/Sources/JayJay/App/Config/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -74,6 +75,10 @@ final class AppSettings {
) }
}

var confirmDragRebase: Bool {
didSet { defaults.set(confirmDragRebase, forKey: StorageKeys.confirmDragRebase) }
}

// MARK: - Layout

var sidebarWidth: Double {
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions shell/mac/Sources/JayJay/App/JayJayApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions shell/mac/Sources/JayJay/Detail/DetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +22,7 @@ struct DetailView: View {
reviewStore: reviewStore, diffStore: diffStore,
compareFromId: compareFromId,
onClearCompare: onClearCompare,
onRevealChangeInDag: onRevealChangeInDag,
activePane: $activePane
)
.id(detail.info.changeId)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions shell/mac/Sources/JayJay/Repo/DAGLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
122 changes: 122 additions & 0 deletions shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading