PadXcode-iPad/Layout/ContentView.swift
2026-04-12 00:46:30 -07:00

358 lines
16 KiB
Swift

import SwiftUI
import Foundation
struct ContentView: View {
@EnvironmentObject var daemonConfig: DaemonConfiguration
@EnvironmentObject var projectStore: ProjectStore
@EnvironmentObject var editorState: EditorState
@EnvironmentObject var gitStore: GitStore
@EnvironmentObject var lspClient: LSPClient
let gitService: GitService
@StateObject private var buildService: BuildService
@StateObject private var syncPipeline: FileSyncPipeline
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var leftPanel: LeftPanel = .files
@State private var consoleHeight: CGFloat = 180
@State private var consoleVisible = true
@State private var activeProject: SavedProject?
@State private var selectedDevice: ConnectedDevice?
@State private var showSettings = false
@State private var showProjectPicker = false
@State private var showFindInProject = false
@State private var showNewFile = false
@State private var showRename = false
@State private var newFilePath = ""
@State private var renamingNode: FileNode?
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
enum LeftPanel { case files, git }
init(gitService: GitService) {
self.gitService = gitService
let config = DaemonConfiguration()
let store = GitStore()
let bs = BuildService(config: config)
_buildService = StateObject(wrappedValue: bs)
_syncPipeline = StateObject(wrappedValue: FileSyncPipeline(
buildService: bs, gitService: gitService,
gitStore: store, config: config))
}
var body: some View {
Group {
if !hasCompletedOnboarding { OnboardingView() }
else { mainIDE }
}
.toastOverlay()
}
// MARK: - Main IDE
private var mainIDE: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
leftColumn
} detail: {
rightColumn
}
.task { await initialLoad() }
.onChange(of: buildService.consoleLines) { _, lines in applyDiagnostics(from: lines) }
.onChange(of: lspClient.diagnostics) { _, diags in
for (path, ranges) in diags { editorState.applyDiagnostics(ranges, toPath: path) }
}
.onReceive(NotificationCenter.default.publisher(for: .gitCommitCompleted)) { _ in
ToastStore.shared.show("Committed successfully", style: .git)
refreshGit()
}
.onReceive(NotificationCenter.default.publisher(for: .gitPushCompleted)) { _ in
ToastStore.shared.show("Pushed to remote", style: .success)
}
.onReceive(NotificationCenter.default.publisher(for: .gitCheckoutCompleted)) { _ in
ToastStore.shared.show("Switched branch", style: .git); refreshGit()
}
.onReceive(NotificationCenter.default.publisher(for: .gitOperationFailed)) { note in
let msg = note.userInfo?["message"] as? String ?? "Git operation failed"
ToastStore.shared.show(msg, style: .error)
}
.onReceive(NotificationCenter.default.publisher(for: .consoleClearRequested)) { _ in
buildService.consoleLines = []
}
.sheet(isPresented: $showSettings) {
SettingsView(editorState: editorState, projectStore: projectStore)
}
.sheet(isPresented: $showProjectPicker) {
ProjectPickerSheet(projectStore: projectStore, activeProject: $activeProject) { project in
Task { await loadProject(project) }
}
}
.sheet(isPresented: $showFindInProject) {
FindAcrossFilesView(
fileTree: buildService.fileTree,
buildService: buildService
) { path, range in
Task { await openFileAtRange(path: path, range: range) }
}
}
.sheet(isPresented: $showNewFile) {
NewFileSheet(parentPath: newFilePath) { parent, name in Task { await createFile(parent: parent, name: name) } }
}
.sheet(isPresented: $showRename) {
if let node = renamingNode {
RenameSheet(node: node) { node, name in Task { await renameFile(node: node, newName: name) } }
}
}
}
// MARK: - Left Column
private var leftColumn: some View {
VStack(spacing: 0) {
Picker("Panel", selection: $leftPanel) {
Label("Files", systemImage: "folder").tag(LeftPanel.files)
Label("Git", systemImage: "arrow.triangle.branch").tag(LeftPanel.git)
}
.pickerStyle(.segmented).padding(.horizontal,8).padding(.vertical,6)
Divider()
switch leftPanel {
case .files:
GitAwareFileNavigatorView(
tree: buildService.fileTree,
fileStatuses: gitStore.fileStatuses,
onSelect: { node in guard !node.isDirectory else { return }; Task { await openFile(node) } },
onCreateFile: { path in newFilePath = path; showNewFile = true },
onCreateFolder: { path in Task { await createFile(parent: path, name: ".gitkeep") } },
onRename: { node in renamingNode = node; showRename = true },
onDelete: { node in Task { await deleteFile(node) } }
)
case .git:
GitPanel(gitStore: gitStore, gitService: gitService)
}
}
.navigationTitle(leftPanel == .files ? (activeProject?.name ?? "Files") : "Source Control")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { showProjectPicker = true } label: { Image(systemName: "folder.badge.gearshape") }
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button { Task { if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } } }
label: { Image(systemName: "arrow.clockwise") }
Button { showSettings = true } label: { Image(systemName: "gearshape") }
}
}
}
// MARK: - Right Column
private var rightColumn: some View {
VStack(spacing: 0) {
BuildToolbar(
scheme: activeProject?.scheme ?? "No Project",
devices: buildService.devices,
selectedDevice: $selectedDevice,
isBuilding: buildService.isBuilding,
buildService: buildService,
onBuild: { triggerBuild("build") },
onRun: { triggerBuild("buildAndRun") },
onRefreshDevices: { Task { await buildService.fetchDevices() } }
)
Divider()
if !editorState.tabs.isEmpty { EditorTabBar(editorState: editorState) }
if let tab = editorState.activeTab {
CodeEditorView(
filePath: tab.path,
content: Binding(
get: { tab.content },
set: { nc in
editorState.updateContent(tabId: tab.id, newContent: nc)
syncPipeline.scheduleSave(path: tab.path, content: nc)
}
),
diagnosticRanges: tab.diagnostics,
lspClient: lspClient,
onSave: { Task { await syncPipeline.saveNow(path: tab.path, content: tab.content)
editorState.markSaved(tabId: tab.id)
ToastStore.shared.show("Saved", style: .success, duration: 1.5) } },
onBuild: { triggerBuild("build") },
onRun: { triggerBuild("buildAndRun") },
onToggleNavigator: { withAnimation { columnVisibility = columnVisibility == .all ? .detailOnly : .all } },
onToggleConsole: { withAnimation { consoleVisible.toggle() } }
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: tab.path) { _, p in
if daemonConfig.lspEnabled { lspClient.didOpen(filePath: p, content: tab.content) }
}
} else {
ContentUnavailableView("No File Open", systemImage: "doc.text",
description: Text("Select a file from the navigator."))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if consoleVisible {
ConsoleResizeHandle(height: $consoleHeight); Divider()
EnhancedConsoleView(lines: buildService.consoleLines).frame(height: consoleHeight)
}
}
}
// MARK: - Actions
private func initialLoad() async {
gitStore.checkInstallation()
await buildService.fetchDevices()
editorState.onSaveRequest = { path, content in await buildService.saveFile(path: path, content: content) }
if let last = projectStore.projects.first { await loadProject(last) }
}
private func loadProject(_ project: SavedProject) async {
activeProject = project
async let tree: Void = buildService.fetchFileTree(projectPath: project.rootPath)
async let lsp: Void = {
if daemonConfig.lspEnabled { await lspClient.connect(projectPath: project.rootPath) }
}()
await tree; await lsp
syncPipeline.registerProject(projectRoot: project.rootPath,
repoName: project.workingCopyRepoName.isEmpty ? project.name : project.workingCopyRepoName)
if gitStore.isWorkingCopyInstalled {
gitStore.activeRepo = project.workingCopyRepoName.isEmpty ? project.name : project.workingCopyRepoName
gitService.fetchStatus(repo: gitStore.activeRepo)
gitService.fetchBranches(repo: gitStore.activeRepo)
}
}
private func openFile(_ node: FileNode) async {
guard let content = await buildService.fetchFileContent(path: node.path) else { return }
editorState.openFile(path: node.path, content: content)
if daemonConfig.lspEnabled { lspClient.didOpen(filePath: node.path, content: content) }
}
private func openFileAtRange(path: String, range: NSRange) async {
await openFile(FileNode(name: URL(fileURLWithPath: path).lastPathComponent,
path: path, isDirectory: false, children: nil))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: .navigateToRange, object: nil, userInfo: ["range": range])
}
}
private func triggerBuild(_ action: String) {
guard let project = activeProject else { ToastStore.shared.show("No project selected", style:.warning); return }
editorState.tabs.forEach { editorState.clearDiagnostics(forPath: $0.path) }
Task {
await buildService.startBuild(
projectPath: project.rootPath, scheme: project.scheme,
selectedDevice: selectedDevice, action: action,
bundleIdentifier: nil)
}
}
private func applyDiagnostics(from lines: [ConsoleLine]) {
let parsed = BuildDiagnosticParser.parse(lines.map(\.text))
let grouped = Dictionary(grouping: parsed, by: \.filePath)
for (path, diags) in grouped {
editorState.applyDiagnostics(diags.map { $0.toDiagnosticRange() }, toPath: path)
}
}
private func refreshGit() {
guard !gitStore.activeRepo.isEmpty else { return }
gitService.fetchStatus(repo: gitStore.activeRepo)
gitService.fetchLog(repo: gitStore.activeRepo)
}
// MARK: - File Operations
private func createFile(parent: String, name: String) async {
guard let url = URL(string: "\(daemonConfig.baseURL)/files/create") else { return }
let body = ["parentPath": parent, "fileName": name, "content": ""]
guard let data = try? JSONEncoder().encode(body) else { return }
var req = URLRequest(url: url); req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type"); req.httpBody = data
if let (_, resp) = try? await URLSession.shared.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 201 {
ToastStore.shared.show("Created \(name)", style: .success)
if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) }
}
}
private func renameFile(node: FileNode, newName: String) async {
guard let url = URL(string: "\(daemonConfig.baseURL)/files/rename") else { return }
let body = ["path": node.path, "newName": newName]
guard let data = try? JSONEncoder().encode(body) else { return }
var req = URLRequest(url: url); req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type"); req.httpBody = data
if let (_, resp) = try? await URLSession.shared.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200 {
ToastStore.shared.show("Renamed to \(newName)", style: .success)
if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) }
}
}
private func deleteFile(_ node: FileNode) async {
guard let encoded = node.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(daemonConfig.baseURL)/files/delete?path=\(encoded)") else { return }
var req = URLRequest(url: url); req.httpMethod = "DELETE"
if let (_, resp) = try? await URLSession.shared.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 204 {
ToastStore.shared.show("Deleted \(node.name)", style: .warning)
if let tab = editorState.tabs.first(where: { $0.path == node.path }) { editorState.closeTab(id: tab.id) }
if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) }
}
}
}
// MARK: - Console Resize Handle
struct ConsoleResizeHandle: View {
@Binding var height: CGFloat
@State private var startHeight: CGFloat = 0
var body: some View {
Rectangle().fill(Color(.systemFill)).frame(height: 6).contentShape(Rectangle())
.gesture(DragGesture()
.onChanged { v in height = max(80, min(600, startHeight - v.translation.height)) }
.onEnded { _ in startHeight = height })
.onAppear { startHeight = height }
}
}
// MARK: - Project Picker Sheet
struct ProjectPickerSheet: View {
@ObservedObject var projectStore: ProjectStore
@Binding var activeProject: SavedProject?
let onSelect: (SavedProject) -> Void
@Environment(\.dismiss) var dismiss
@State private var showAdd = false
var body: some View {
NavigationStack {
List {
ForEach(projectStore.projects) { p in
Button {
activeProject = p; onSelect(p); dismiss()
} label: {
HStack {
VStack(alignment:.leading, spacing:3) {
Text(p.name).font(.headline).foregroundStyle(.primary)
Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary)
}
Spacer()
if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(.accentColor) }
}
}.buttonStyle(.plain)
}
.onDelete { projectStore.remove(atOffsets: $0) }
}
.navigationTitle("Projects").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .navigationBarTrailing) { Button { showAdd = true } label: { Image(systemName:"plus") } }
}
.sheet(isPresented: $showAdd) { AddProjectView(projectStore: projectStore) }
}
.presentationDetents([.medium, .large])
}
}