249 lines
10 KiB
Swift
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()
|
|
}
|
|
}
|