Files
nucleic/Sources/NucleicApp/Sheets.swift
T

249 lines
10 KiB
Swift

import SwiftUI
import AppKit
import NucleicCore
/// Register a git repository as a project.
struct AddProjectSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var path = ""
@State private var defaultBranch = "main"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Add Project").font(.title2).bold()
HStack {
TextField("Repository path", text: $path)
Button("Choose…") { chooseFolder() }
}
TextField("Name", text: $name)
TextField("Default branch", text: $defaultBranch)
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Add") { Task { await add() } }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(name.isEmpty || path.isEmpty || defaultBranch.isEmpty)
}
}
.padding(20)
.frame(width: 460)
}
private func chooseFolder() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
path = url.path
if name.isEmpty { name = url.lastPathComponent }
}
}
private func add() async {
_ = await store.addProject(name: name, rootPath: path, defaultBranch: GitRef(defaultBranch))
dismiss()
}
}
/// Per-project settings — currently the execution sandbox. When enabled, the project's
/// sessions run `claude` inside an isolated Apple `container` (a Linux VM) with the
/// worktree bind-mounted, instead of directly on the host.
struct ProjectSettingsSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
let project: Project
@State private var defaultBranch: String
@State private var sandboxEnabled: Bool
@State private var image: String
@State private var idleMinutes: Int
@State private var allowHostExec: Bool
@State private var showMove = false
init(project: Project) {
self.project = project
_defaultBranch = State(initialValue: project.defaultBranch.value)
let sandbox = project.sandbox ?? ProjectSandbox()
_sandboxEnabled = State(initialValue: sandbox.enabled)
_image = State(initialValue: sandbox.image ?? "")
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
_allowHostExec = State(initialValue: sandbox.allowHostExec)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("\(project.name) Settings").font(.title2).bold()
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Default branch")
TextField("main", text: $defaultBranch)
.textFieldStyle(.roundedBorder)
}
Text("New sessions branch their worktree from this ref unless overridden "
+ "when starting the session.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(14)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
VStack(alignment: .leading, spacing: 10) {
Toggle(isOn: $sandboxEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Run sessions in a sandbox container")
Text("Executes Claude inside an isolated Linux VM (Apple `container`) "
+ "with this repo's worktree mounted. Requires Apple Silicon + the "
+ "`container` tool installed.")
.font(.caption).foregroundStyle(.secondary)
}
}
if sandboxEnabled {
Divider()
HStack {
Text("Image")
TextField(ProjectSandbox.defaultImage, text: $image)
.textFieldStyle(.roundedBorder)
}
Text("Leave blank to use the bundled default image (built on first use).")
.font(.caption).foregroundStyle(.secondary)
Stepper(
"Stop container after \(idleMinutes) min idle",
value: $idleMinutes, in: 1...240)
Divider()
Toggle(isOn: $allowHostExec) {
VStack(alignment: .leading, spacing: 2) {
Text("Allow host build/run")
Text("Lets the sandboxed agent request to build and run executables "
+ "on the host machine (outside the container) — e.g. compiling "
+ "and running a macOS binary. Every host command needs your "
+ "explicit approval and is never auto-approved.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
.padding(14)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Repository location")
Text(project.rootPath)
.font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
Spacer()
Button("Move…") { showMove = true }
}
Text("Relocate this repo to a new folder — e.g. out of iCloud. Live chats "
+ "pause and resume automatically; the original is removed only after the "
+ "move is verified.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(14)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Save") { Task { await save() } }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
}
.padding(20)
.frame(width: 480)
.sheet(isPresented: $showMove) { MoveProjectSheet(project: project) }
}
private func save() async {
var updated = project
let branch = defaultBranch.trimmingCharacters(in: .whitespacesAndNewlines)
if !branch.isEmpty {
updated.defaultBranch = GitRef(branch)
}
let trimmed = image.trimmingCharacters(in: .whitespacesAndNewlines)
updated.sandbox = ProjectSandbox(
enabled: sandboxEnabled,
image: trimmed.isEmpty ? nil : trimmed,
idleTimeoutSeconds: idleMinutes * 60,
allowHostExec: allowHostExec)
await store.updateProject(updated)
dismiss()
}
}
/// Safe-move a project's repository to a new location (typically out of iCloud). Picks a
/// destination *parent* folder; the repo keeps its current folder name. Delegates the
/// stop-sessions → copy → repair → verify → rewrite → delete-source work to
/// `AppStore.moveProject`.
struct MoveProjectSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
let project: Project
@State private var destParent = ""
private var folderName: String { (project.rootPath as NSString).lastPathComponent }
private var resolvedPath: String {
destParent.isEmpty ? "" : (destParent as NSString).appendingPathComponent(folderName)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Move Repository").font(.title2).bold()
Text("Copies “\(project.name)” into the chosen folder, repairs its git "
+ "worktrees, updates Nucleic, then removes the original. Any running "
+ "chats are paused and become resumable.")
.font(.caption).foregroundStyle(.secondary)
HStack {
TextField("Destination folder", text: $destParent)
Button("Choose…") { chooseFolder() }
}
if !resolvedPath.isEmpty {
Text("New location: \(resolvedPath)")
.font(.caption).foregroundStyle(.secondary)
.lineLimit(2).truncationMode(.middle)
}
HStack {
if store.moving { ProgressView().controlSize(.small); Text("Moving…").font(.caption) }
Spacer()
Button("Cancel") { dismiss() }.disabled(store.moving)
Button("Move") { Task { await move() } }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(destParent.isEmpty || store.moving)
}
}
.padding(20)
.frame(width: 480)
}
private func chooseFolder() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.prompt = "Choose Destination"
if panel.runModal() == .OK, let url = panel.url { destParent = url.path }
}
private func move() async {
await store.moveProject(project.id, toParent: URL(fileURLWithPath: destParent))
dismiss()
}
}