359 lines
16 KiB
Swift
359 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])
|
||
|
|
}
|
||
|
|
}
|