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
6 changes: 3 additions & 3 deletions OhMyWorktree/Models/CopiedFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ enum CopiedFileStatus: Equatable, Sendable {
case new // present in the worktree, absent in main
case missing // present in main, absent in the worktree (the copy never happened)

/// Differs from main (anything worth surfacing / applying).
/// Differs from main. Drives the changed-count, the "Changed only" filter, and
/// chip/row emphasis. Every copied file is openable in the diff tool regardless,
/// so there is no separate "clickable" flag.
var isChanged: Bool { self != .identical }
/// Has something to open (a diff to view or content to copy back).
var isClickable: Bool { self != .identical }
/// Ordering for chips/list: modified → missing → new → identical.
var sortRank: Int {
switch self {
Expand Down
81 changes: 81 additions & 0 deletions OhMyWorktree/Models/DiffTool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

/// A pure, table-driven catalog of external diff/merge tools the app can hand a
/// copied file off to. Holds display metadata, the CLI invocation, and the
/// candidate command paths used for detection. All logic here is side-effect free
/// so it is fully unit-tested; the actual detection + `Process` launch live in the
/// coverage-excluded `DiffToolLauncher`.
struct DiffTool: Identifiable, Equatable, Sendable {
let id: String
let name: String
/// SF Symbol used in the picker.
let sfSymbol: String
/// Flags inserted before the two file paths (e.g. VS Code's `--diff`).
let extraArguments: [String]
/// Absolute command paths tried in order; the first that exists wins.
let commandCandidates: [String]

/// Full argument vector for diffing `mainPath` (left) against `worktreePath` (right).
func launchArguments(mainPath: String, worktreePath: String) -> [String] {
extraArguments + [mainPath, worktreePath]
}

/// The five tools from the design, in display order.
static let all: [DiffTool] = [
DiffTool(
id: "araxis", name: "Araxis Merge", sfSymbol: "rectangle.split.2x1",
extraArguments: [],
// Bundle path first: ImageMagick also installs a `compare` on PATH.
commandCandidates: [
"/Applications/Araxis Merge.app/Contents/Utilities/compare",
"/usr/local/bin/compare"
]
),
DiffTool(
id: "kaleidoscope", name: "Kaleidoscope", sfSymbol: "circle.lefthalf.filled",
extraArguments: [],
commandCandidates: [
"/usr/local/bin/ksdiff",
"/opt/homebrew/bin/ksdiff",
"/Applications/Kaleidoscope.app/Contents/Resources/bin/ksdiff"
]
),
DiffTool(
id: "bcompare", name: "Beyond Compare", sfSymbol: "arrow.left.arrow.right",
extraArguments: [],
commandCandidates: [
"/usr/local/bin/bcomp",
"/Applications/Beyond Compare.app/Contents/MacOS/bcomp"
]
),
DiffTool(
id: "vscode", name: "VS Code", sfSymbol: "chevron.left.forwardslash.chevron.right",
extraArguments: ["--diff"],
commandCandidates: [
"/usr/local/bin/code",
"/opt/homebrew/bin/code",
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
]
),
DiffTool(
id: "filemerge", name: "FileMerge", sfSymbol: "doc.on.doc",
extraArguments: [],
commandCandidates: ["/usr/bin/opendiff"]
)
]

static func find(id: String) -> DiffTool? {
all.first { $0.id == id }
}

/// The subset of `all` for which `probe` returns true (probe = "is installed?").
static func installed(probe: (DiffTool) -> Bool) -> [DiffTool] {
all.filter(probe)
}

/// The tool that should actually be used: the stored choice if it is installed,
/// otherwise the first installed tool, otherwise nil (nothing installed).
static func effective(storedID: String?, installed: [DiffTool]) -> DiffTool? {
installed.first { $0.id == storedID } ?? installed.first
}
}
29 changes: 1 addition & 28 deletions OhMyWorktree/Services/CopiedFileDiffer.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import Foundation

/// Compares a worktree's copied (`.worktreeinclude`) files against the
/// repository's (main worktree's) copies, and applies a worktree copy back to main.
/// Stateless; safe to instantiate per call.
/// repository's (main worktree's) copies. Stateless; safe to instantiate per call.
final class CopiedFileDiffer: Sendable {

/// Builds a `CopiedFile` per relative path by reading both copies. A path may
Expand All @@ -26,30 +25,4 @@ final class CopiedFileDiffer: Sendable {
: lhs.path < rhs.path
}
}

/// Overwrites main's local copy of `file` with the worktree's bytes (atomically;
/// creates the file and parent directories when absent). Never touches Git.
func applyToMain(_ file: CopiedFile, worktreePath: String, repositoryPath: String) throws {
let fm = FileManager.default
let source = URL(fileURLWithPath: (worktreePath as NSString).appendingPathComponent(file.path))
let dest = URL(fileURLWithPath: (repositoryPath as NSString).appendingPathComponent(file.path))
let destDir = (dest.path as NSString).deletingLastPathComponent
try fm.createDirectory(atPath: destDir, withIntermediateDirectories: true)
let data = try Data(contentsOf: source)
try data.write(to: dest, options: .atomic)
}

/// Copies main's local copy of `file` into the worktree (atomically; creates the
/// file and parent directories when absent). The inverse of `applyToMain`, for
/// `.missing` files — performs the `.worktreeinclude` copy that never ran. Never
/// touches Git.
func applyToWorktree(_ file: CopiedFile, worktreePath: String, repositoryPath: String) throws {
let fm = FileManager.default
let source = URL(fileURLWithPath: (repositoryPath as NSString).appendingPathComponent(file.path))
let dest = URL(fileURLWithPath: (worktreePath as NSString).appendingPathComponent(file.path))
let destDir = (dest.path as NSString).deletingLastPathComponent
try fm.createDirectory(atPath: destDir, withIntermediateDirectories: true)
let data = try Data(contentsOf: source)
try data.write(to: dest, options: .atomic)
}
}
39 changes: 39 additions & 0 deletions OhMyWorktree/Services/DiffToolLauncher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

/// Detects installed external diff tools and launches one on a file pair.
/// The impure half of the diff-tool feature (filesystem probing + `Process`);
/// the testable catalog/arg logic lives in `DiffTool`. Coverage-excluded.
final class DiffToolLauncher: Sendable {

private let fileExists: @Sendable (String) -> Bool

init(fileExists: @escaping @Sendable (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) {
self.fileExists = fileExists
}

/// First existing command path for `tool`, or nil if none is present.
func resolvedCommand(for tool: DiffTool) -> String? {
tool.commandCandidates.first(where: fileExists)
}

func isInstalled(_ tool: DiffTool) -> Bool {
resolvedCommand(for: tool) != nil
}

/// The installed subset of `DiffTool.all`, in catalog order.
func installedTools() -> [DiffTool] {
DiffTool.installed { isInstalled($0) }
}

/// Launches `tool` to diff `mainPath` (left) against `worktreePath` (right).
/// Fire-and-forget: diff tools are long-lived GUI apps, so we do not wait.
func launch(_ tool: DiffTool, mainPath: String, worktreePath: String) throws {
guard let command = resolvedCommand(for: tool) else {
throw OhMyWorktreeError.externalToolNotFound(tool: tool.name)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: command)
process.arguments = tool.launchArguments(mainPath: mainPath, worktreePath: worktreePath)
try process.run()
}
}
55 changes: 5 additions & 50 deletions OhMyWorktree/ViewModels/WorktreeListViewModel+CopiedFiles.swift
Original file line number Diff line number Diff line change
@@ -1,65 +1,20 @@
import Foundation

// MARK: - Copied-files browser + apply-to-main glue
// MARK: - Copied-files browser glue
//
// Thin view-model glue: opening/closing the browser, drilling into a file, and
// applying a copy back to main. The real work (diff + atomic write) lives in the
// tested `CopiedFileDiffer`; this layer only coordinates state and reloads detail.
// Thin view-model glue for the copied-files browser. Diffs are computed by the
// tested `CopiedFileDiffer`; opening a file hands off to an external diff tool
// (see `openInDiffTool` in WorktreeListViewModel+ExternalTools).

extension WorktreeListViewModel {

/// Open state for the copied-files browser sheet.
struct CopiedBrowserState: Equatable {
var worktree: Worktree
var focusedPath: String?
}

/// Opens the browser to the full list for `worktree`.
func browseCopiedFiles(for worktree: Worktree) {
copiedBrowser = CopiedBrowserState(worktree: worktree, focusedPath: nil)
}

/// Opens the browser straight to `file`'s diff for `worktree`.
func openCopiedDiff(_ file: CopiedFile, for worktree: Worktree) {
copiedBrowser = CopiedBrowserState(worktree: worktree, focusedPath: file.path)
}

/// Applies `file` (worktree → main), reloads the detail so statuses refresh,
/// and shows a toast. Surfaces failures through the standard error alert.
func applyCopiedFileToMain(_ file: CopiedFile, in worktree: Worktree) async {
guard let repository else { return }
let differ = self.differ
let worktreePath = worktree.path
let repoPath = repository.path
do {
try await Task.detached {
try differ.applyToMain(file, worktreePath: worktreePath, repositoryPath: repoPath)
}.value
await loadDetail(for: worktree, clearingFirst: false)
copiedBrowser?.focusedPath = nil
copiedToast = "Applied \((file.path as NSString).lastPathComponent) to main"
} catch {
errorMessage = error.localizedDescription
}
}

/// Copies `file` (main → worktree) to perform a `.worktreeinclude` copy that
/// never happened, reloads the detail so statuses refresh, and shows a toast.
/// Surfaces failures through the standard error alert.
func applyCopiedFileToWorktree(_ file: CopiedFile, in worktree: Worktree) async {
guard let repository else { return }
let differ = self.differ
let worktreePath = worktree.path
let repoPath = repository.path
do {
try await Task.detached {
try differ.applyToWorktree(file, worktreePath: worktreePath, repositoryPath: repoPath)
}.value
await loadDetail(for: worktree, clearingFirst: false)
copiedBrowser?.focusedPath = nil
copiedToast = "Copied \((file.path as NSString).lastPathComponent) to worktree"
} catch {
errorMessage = error.localizedDescription
}
copiedBrowser = CopiedBrowserState(worktree: worktree)
}
}
21 changes: 21 additions & 0 deletions OhMyWorktree/ViewModels/WorktreeListViewModel+ExternalTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,25 @@ extension WorktreeListViewModel {
var isVSCodeAvailable: Bool { toolLauncher.isVSCodeInstalled() }
var isCursorAvailable: Bool { toolLauncher.isCursorInstalled() }
var isCmuxAvailable: Bool { toolLauncher.isCmuxInstalled() }

// MARK: - Diff Tools

var availableDiffTools: [DiffTool] { diffToolLauncher.installedTools() }

/// Hands `file` off to the user's chosen diff tool, comparing main's copy
/// (left) against the worktree's copy (right). No-op when nothing is installed.
func openInDiffTool(_ file: CopiedFile, in worktree: Worktree) async {
guard let repository else { return }
let installed = diffToolLauncher.installedTools()
let storedID = UserDefaults.standard.string(forKey: "diffToolID")
guard let tool = DiffTool.effective(storedID: storedID, installed: installed) else { return }
let mainPath = (repository.path as NSString).appendingPathComponent(file.path)
let worktreePath = (worktree.path as NSString).appendingPathComponent(file.path)
do {
try diffToolLauncher.launch(tool, mainPath: mainPath, worktreePath: worktreePath)
copiedToast = "Opening \((file.path as NSString).lastPathComponent) in \(tool.name)"
} catch {
errorMessage = error.localizedDescription
}
}
}
10 changes: 5 additions & 5 deletions OhMyWorktree/ViewModels/WorktreeListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,9 @@ final class WorktreeListViewModel {
/// Ahead/behind, diff stat, and recent commits for the currently selected worktree.
var selectedWorktreeDetail: WorktreeDetail?

/// When non-nil, the copied-files browser sheet is open. `focusedPath`, when
/// set, opens straight to that file's diff (drill-in); nil shows the list.
/// When non-nil, the copied-files browser sheet is open (list of copied files).
var copiedBrowser: CopiedBrowserState?
/// A copied file awaiting Apply-to-main confirmation.
var pendingApply: CopiedFile?
/// Transient success message shown as a toast after an apply.
/// Transient status message shown as a toast (e.g. opening a diff externally).
var copiedToast: String?

// FR-032: Multi-select (native ⌘+click / ⇧+click via SwiftUI List)
Expand Down Expand Up @@ -70,6 +67,7 @@ final class WorktreeListViewModel {
let worktreeManager: WorktreeManager
// internal for WorktreeListViewModel+ExternalTools extension
let toolLauncher: ExternalToolLauncher
let diffToolLauncher: DiffToolLauncher
// internal for WorktreeListViewModel+ExternalTools and handleJobStateChange
let store: RepositoryStore
// internal for WorktreeListViewModel+Creation extension
Expand All @@ -95,13 +93,15 @@ final class WorktreeListViewModel {
init(
worktreeManager: WorktreeManager = WorktreeManager(),
toolLauncher: ExternalToolLauncher = ExternalToolLauncher(),
diffToolLauncher: DiffToolLauncher = DiffToolLauncher(),
store: RepositoryStore = .shared,
fileCopier: WorktreeFileCopier = WorktreeFileCopier(),
differ: CopiedFileDiffer = CopiedFileDiffer(),
pullRequestService: PullRequestFetching = PullRequestService()
) {
self.worktreeManager = worktreeManager
self.toolLauncher = toolLauncher
self.diffToolLauncher = diffToolLauncher
self.store = store
self.fileCopier = fileCopier
self.differ = differ
Expand Down
19 changes: 8 additions & 11 deletions OhMyWorktree/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,11 @@ struct ContentView: View {
onOpenPR: {
if let wt = selectedWorktree { worktreeViewModel.openPullRequest(for: wt) }
},
onOpenCopiedDiff: { file in
if let wt = selectedWorktree { worktreeViewModel.openCopiedDiff(file, for: wt) }
availableDiffTools: worktreeViewModel.availableDiffTools,
onOpenInDiffTool: { file in
if let wt = selectedWorktree {
Task { await worktreeViewModel.openInDiffTool(file, in: wt) }
}
},
onBrowseCopied: {
if let wt = selectedWorktree { worktreeViewModel.browseCopiedFiles(for: wt) }
Expand Down Expand Up @@ -257,15 +260,9 @@ struct ContentView: View {
glassModal {
CopiedFilesBrowser(
files: worktreeViewModel.selectedWorktreeDetail?.copiedFiles ?? [],
focusedPath: Binding(
get: { worktreeViewModel.copiedBrowser?.focusedPath },
set: { worktreeViewModel.copiedBrowser?.focusedPath = $0 }
),
onApply: { file in worktreeViewModel.pendingApply = file },
onApplyToWorktree: { file in
if let target = worktreeViewModel.copiedBrowser?.worktree {
Task { await worktreeViewModel.applyCopiedFileToWorktree(file, in: target) }
}
availableDiffTools: worktreeViewModel.availableDiffTools,
onOpenInDiffTool: { file in
Task { await worktreeViewModel.openInDiffTool(file, in: browser.worktree) }
},
onClose: { worktreeViewModel.copiedBrowser = nil }
)
Expand Down
25 changes: 9 additions & 16 deletions OhMyWorktree/Views/Detail/CopiedFileRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,25 @@ struct CopiedFileRow: View {
let file: CopiedFile
var onSelect: () -> Void

// Identical rows dim via color tier only and render as a non-button: a whole-row
// opacity multiplied the already-tertiary directory/dot colors (plus the
// disabled-button dim) into illegibility in light mode.
@ViewBuilder
// Every copied file opens in the external diff tool (identical included), so the
// row is always a button. Identical rows dim via color tier only — never a whole-row
// opacity, which stacked with the tertiary text into illegibility in light mode.
var body: some View {
if file.status.isClickable {
Button { onSelect() } label: { rowContent }
.buttonStyle(.plain)
} else {
rowContent
}
Button { onSelect() } label: { rowContent }
.buttonStyle(.plain)
}

private var rowContent: some View {
HStack(spacing: 9) {
Circle().fill(dotColor).frame(width: 7, height: 7)
PathLabel(path: file.path)
.font(.omwMono(12.5, weight: .medium))
.foregroundStyle(file.status.isClickable ? OMWColor.labelPrimary : OMWColor.labelSecondary)
.foregroundStyle(file.status.isChanged ? OMWColor.labelPrimary : OMWColor.labelSecondary)
Spacer(minLength: 8)
rowBadge
if file.status.isClickable {
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundStyle(OMWColor.labelTertiary)
}
Image(systemName: "arrow.up.forward.app")
.font(.system(size: 12))
.foregroundStyle(OMWColor.labelTertiary)
}
.padding(.horizontal, 11).frame(height: 38)
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down
Loading
Loading