Files
nucleic/Sources/NucleicApp/LockQueueView.swift
T
abkslmandClaude Opus 4.8 27f9a51603 Add "Release All" to the file-lock queue viewer
Header button (shown only when locks are held) force-releases every holding
session's locks at once, behind a confirmation, so all queued agents proceed.
Each lock re-arms once its session merges or edits again.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 01:54:28 -07:00

261 lines
10 KiB
Swift

import SwiftUI
import NucleicCore
/// Cross-session file-lock queue: which sessions hold a lock on each file (their
/// unmerged changes are the implicit lock) and which sessions are queued behind them
/// via "Wait for Access". Refreshes live while open — the holder footprints come from
/// git (unmerged files), so this polls rather than binding to an observable.
struct LockQueueView: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@Environment(\.dismiss) private var dismiss
@State private var snapshot = LockQueueSnapshot(files: [], waiters: [])
@State private var loaded = false
@State private var confirmingReleaseAll = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
content
Divider()
HStack {
Text(footerSummary).font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
}
.padding(16)
}
.frame(width: 560, height: 560)
.task { await poll() }
.confirmationDialog(
"Release all file locks?",
isPresented: $confirmingReleaseAll, titleVisibility: .visible
) {
Button("Release All Locks", role: .destructive) { releaseAll() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Clears every session's hold on its changed files so all queued agents "
+ "can proceed. Each lock re-arms once that session merges or edits again.")
}
}
/// Sessions currently holding at least one file lock.
private var holderIDs: [SessionID] {
var seen = Set<SessionID>()
return snapshot.files
.flatMap { $0.holders.map(\.sessionID) }
.filter { seen.insert($0).inserted }
}
// Poll the snapshot while the sheet is open. Cancels automatically on dismiss.
private func poll() async {
while !Task.isCancelled {
snapshot = await store.lockQueueSnapshot()
loaded = true
try? await Task.sleep(for: .seconds(2))
}
}
private var header: some View {
HStack(spacing: 10) {
Image(systemName: "lock.doc")
.font(.title2).foregroundStyle(palette.accent)
VStack(alignment: .leading, spacing: 2) {
Text("File Locks").font(.title2).bold()
Text("Which sessions hold each file and which are queued behind them.")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
if !holderIDs.isEmpty {
Button("Release All", systemImage: "lock.open") {
confirmingReleaseAll = true
}
.help("Force-release every session's file locks so all queued agents can proceed")
}
}
.padding(16)
}
@ViewBuilder
private var content: some View {
if !loaded {
centered { ProgressView() }
} else if snapshot.isEmpty {
centered {
VStack(spacing: 8) {
Image(systemName: "lock.open")
.font(.largeTitle).foregroundStyle(.secondary)
Text("No active file locks")
.font(.headline)
Text("A session holds a lock on the files it has changed but not yet "
+ "merged. Locks clear automatically once the work merges.")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
}
}
} else {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if !snapshot.files.isEmpty {
section("Locked files") {
ForEach(snapshot.files) { lock in
FileLockCard(lock: lock, onOpen: open, onRelease: release)
}
}
}
// Waiters with no predicted files won't appear under any file above;
// surface every queued session here so none is hidden.
let unfiled = snapshot.waiters.filter { $0.wantedFiles.isEmpty }
if !unfiled.isEmpty {
section("Queued (no specific file)") {
ForEach(unfiled) { waiter in
WaiterCard(waiter: waiter, onOpen: open)
}
}
}
}
.padding(16)
}
}
}
@ViewBuilder
private func section(_ title: String, @ViewBuilder _ body: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title.uppercased())
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
body()
}
}
private func centered(@ViewBuilder _ body: () -> some View) -> some View {
VStack { Spacer(); body(); Spacer() }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
}
private var footerSummary: String {
let holders = Set(snapshot.files.flatMap { $0.holders.map(\.sessionID) }).count
let waiters = snapshot.waiters.count
if holders == 0 && waiters == 0 { return "No locks held" }
let h = "\(holders) session\(holders == 1 ? "" : "s") holding \(snapshot.files.count) file\(snapshot.files.count == 1 ? "" : "s")"
return waiters == 0 ? h : "\(h) · \(waiters) waiting"
}
private func open(_ sessionID: SessionID) {
store.openSessionID = sessionID
dismiss()
}
private func release(_ sessionID: SessionID) {
Task {
await store.forceReleaseLocks(sessionID)
snapshot = await store.lockQueueSnapshot()
}
}
private func releaseAll() {
let ids = holderIDs
Task {
for id in ids { await store.forceReleaseLocks(id) }
snapshot = await store.lockQueueSnapshot()
}
}
}
/// One locked file: the path, its holder(s) (green lock), and queued waiter(s) (amber).
private struct FileLockCard: View {
@Environment(\.appPalette) private var palette
let lock: FileLock
let onOpen: (SessionID) -> Void
let onRelease: (SessionID) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(lock.path)
.font(.callout.monospaced()).bold()
.lineLimit(1).truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(lock.holders) { holder in
participantRow(
holder, icon: "lock.fill", tint: palette.success, role: "Holding",
trailing: {
Button("Release") { onRelease(holder.sessionID) }
.buttonStyle(.borderless).font(.caption)
.help("Force-release this session's lock so queued agents can proceed. "
+ "It re-arms once the session merges or edits again.")
})
}
if !lock.waiters.isEmpty {
Divider()
ForEach(lock.waiters) { waiter in
participantRow(
waiter, icon: "hourglass", tint: palette.attention, role: "Waiting",
trailing: { EmptyView() })
}
}
}
.padding(12)
.background(AppTheme.surface, in: .rect(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
@ViewBuilder
private func participantRow(
_ p: LockParticipant, icon: String, tint: Color, role: String,
@ViewBuilder trailing: () -> some View
) -> some View {
HStack(spacing: 8) {
Image(systemName: icon).foregroundStyle(tint).frame(width: 16)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text(role).font(.caption2.weight(.semibold)).foregroundStyle(tint)
Button(p.title) { onOpen(p.sessionID) }
.buttonStyle(.plain).font(.callout).lineLimit(1)
.help("Open “\(p.title)”")
}
if !p.task.isEmpty {
Text(p.task).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer(minLength: 8)
trailing()
}
}
}
/// A queued session that predicted no specific file (it matched another agent's work
/// by intent). Shows what it's waiting on and what's blocking it.
private struct WaiterCard: View {
@Environment(\.appPalette) private var palette
let waiter: WaitingSession
let onOpen: (SessionID) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "hourglass").foregroundStyle(palette.attention).frame(width: 16)
Button(waiter.title) { onOpen(waiter.sessionID) }
.buttonStyle(.plain).font(.callout).bold().lineLimit(1)
Spacer()
}
if !waiter.task.isEmpty {
Text(waiter.task).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
if !waiter.blockedBy.isEmpty {
Text("Blocked by " + waiter.blockedBy.map(\.title).joined(separator: ", "))
.font(.caption2).foregroundStyle(.secondary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: .rect(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
}