project improvements
This commit is contained in:
parent
3310282d4b
commit
6e9f03b487
34 changed files with 1123 additions and 394 deletions
|
|
@ -51,6 +51,7 @@ struct BuildRecord: Identifiable, Codable {
|
|||
|
||||
// MARK: - Store
|
||||
|
||||
@MainActor
|
||||
final class BuildHistoryStore: ObservableObject {
|
||||
@Published var records: [BuildRecord] = []
|
||||
private let maxRecords = 50
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class DaemonConfiguration: ObservableObject {
|
||||
@AppStorage("daemonHost") var host: String = "100.x.x.x"
|
||||
@AppStorage("daemonPort") var port: Int = 8080
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ struct LocalGitServer: Codable, Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LocalGitServerStore: ObservableObject {
|
||||
@Published var servers: [LocalGitServer] = [] { didSet { persist() } }
|
||||
private let key = "padxcode.localGitServers"
|
||||
|
|
@ -78,9 +79,9 @@ git push -u origin main
|
|||
}
|
||||
|
||||
private struct SetupStep: View {
|
||||
let step: String; let title: String; let body: String
|
||||
let step: String; let title: String; let detail: String
|
||||
init(_ step: String, title: String, body: String) {
|
||||
self.step = step; self.title = title; self.body = body
|
||||
self.step = step; self.title = title; self.detail = body
|
||||
}
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
|
|
@ -89,7 +90,7 @@ private struct SetupStep: View {
|
|||
.background(Color.accentColor, in: Circle())
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
Text(body).font(.subheadline).foregroundStyle(.secondary)
|
||||
Text(detail).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ struct SavedProject: Identifiable, Codable {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProjectStore: ObservableObject {
|
||||
@Published var projects: [SavedProject] = [] { didSet { persist() } }
|
||||
private let key = "padxcode.projects"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ struct ParsedDiagnostic {
|
|||
|
||||
struct BuildDiagnosticParser {
|
||||
private static let pattern = try! NSRegularExpression(
|
||||
pattern: #"^(.+\.swift):(\d+):(\d+):\s+(error|warning|note):\s+(.+)$"#
|
||||
pattern: #"^(.+\.swift):(\d+):(\d+):\s+(error|warning):\s+(.+)$"#
|
||||
)
|
||||
|
||||
static func parse(_ lines: [String]) -> [ParsedDiagnostic] {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,18 @@ struct CodeEditorView: View {
|
|||
.onReceive(NotificationCenter.default.publisher(for: .navigateToRange)) { note in
|
||||
if let range = note.userInfo?["range"] as? NSRange { navigate(to: range) }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .editorFontSizeChange)) { note in
|
||||
if let delta = note.userInfo?["delta"] as? Double {
|
||||
let newSize = max(8, min(32, editorState.fontSizeStored + delta))
|
||||
editorState.fontSizeStored = newSize
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .editorFindNext)) { _ in
|
||||
keyboardBridge.triggerFindNext()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .editorFindPrev)) { _ in
|
||||
keyboardBridge.triggerFindPrev()
|
||||
}
|
||||
}
|
||||
|
||||
private func jumpTo(_ line: Int) {
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ final class CompletionController: ObservableObject {
|
|||
func attach(to tv: Runestone.TextView) { self.textView = tv }
|
||||
|
||||
func textDidChange(in tv: Runestone.TextView, filePath: String) {
|
||||
guard let sel = tv.selectedRange, sel.location > 0 else { dismiss(); return }
|
||||
let sel = tv.selectedRange
|
||||
guard sel.location > 0 else { dismiss(); return }
|
||||
let text = tv.text as NSString
|
||||
let c = text.character(at: sel.location - 1)
|
||||
let shouldTrigger = autoTriggers.contains(Character(Unicode.Scalar(c)!))
|
||||
let shouldTrigger = Unicode.Scalar(c).map { autoTriggers.contains(Character($0)) } ?? false
|
||||
|
||||
if shouldTrigger {
|
||||
requestCompletions(tv: tv, filePath: filePath)
|
||||
|
|
@ -30,7 +31,7 @@ final class CompletionController: ObservableObject {
|
|||
debounceTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(200))
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.requestCompletions(tv: tv, filePath: filePath)
|
||||
self?.requestCompletions(tv: tv, filePath: filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,28 +43,33 @@ final class CompletionController: ObservableObject {
|
|||
func dismiss() { debounceTask?.cancel(); items = []; isVisible = false }
|
||||
|
||||
func insert(_ item: CompletionItem, into tv: Runestone.TextView) {
|
||||
guard let sel = tv.selectedRange else { dismiss(); return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString
|
||||
let wordRange = text.rangeOfCurrentWord(at: sel.location)
|
||||
let insertText = item.insertText ?? item.label
|
||||
tv.replace(wordRange, withText: insertText)
|
||||
if insertText.hasSuffix("()") {
|
||||
let loc = (tv.selectedRange?.location ?? 1) - 1
|
||||
let loc = tv.selectedRange.location > 0 ? tv.selectedRange.location - 1 : 0
|
||||
tv.selectedRange = NSRange(location: loc, length: 0)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func requestCompletions(tv: Runestone.TextView, filePath: String) {
|
||||
guard let sel = tv.selectedRange,
|
||||
let loc = tv.textLocation(at: sel.location) else { return }
|
||||
if let rect = tv.caretRect(for: sel.location) {
|
||||
anchorRect = tv.convert(rect, to: nil)
|
||||
}
|
||||
let sel = tv.selectedRange
|
||||
guard let loc = tv.textLocation(at: sel.location) else { return }
|
||||
// caretRect(for:) on Runestone.TextView takes UITextPosition, not Int.
|
||||
// Approximate anchor position from font metrics for the completion popover.
|
||||
let font = tv.theme.font
|
||||
let lineHeight = ceil(font.lineHeight * tv.lineHeightMultiplier)
|
||||
let approxY = CGFloat(loc.lineNumber) * lineHeight
|
||||
+ tv.textContainerInset.top
|
||||
- tv.contentOffset.y
|
||||
anchorRect = CGRect(x: 0, y: approxY, width: 0, height: lineHeight)
|
||||
lastId += 1; let id = lastId
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let results = await lspClient.requestCompletions(filePath: filePath, line: loc.lineNumber, column: loc.column)
|
||||
let results = await lspClient.requestCompletions(filePath: filePath, line: loc.lineNumber + 1, column: loc.column + 1)
|
||||
guard self.lastId == id else { return }
|
||||
if results.isEmpty { self.dismiss() } else { self.items = results; self.isVisible = true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,50 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
import Runestone
|
||||
|
||||
// MARK: - Keyboard Bridge
|
||||
|
||||
final class EditorKeyboardBridge: ObservableObject {
|
||||
weak var activeTextView: Runestone.TextView?
|
||||
|
||||
// Called by CodeEditorView when ⌘G / ⌘⇧G hardware shortcut fires
|
||||
var onFindNext: (() -> Void)?
|
||||
var onFindPrev: (() -> Void)?
|
||||
|
||||
func triggerFindNext() { onFindNext?() }
|
||||
func triggerFindPrev() { onFindPrev?() }
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
struct EditorKeyboardToolbar: View {
|
||||
@ObservedObject var bridge: EditorKeyboardBridge
|
||||
@State private var showFindBar = false
|
||||
@State private var searchQuery = ""
|
||||
@State private var isRegex = false
|
||||
@State private var lastMatchRange: NSRange = NSRange(location: NSNotFound, length: 0)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showFindBar {
|
||||
InlineFindBar(query: $searchQuery, isRegex: $isRegex,
|
||||
onNext: findNext, onPrev: findPrev,
|
||||
onDismiss: { withAnimation { showFindBar = false } })
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
InlineFindBar(
|
||||
query: $searchQuery,
|
||||
onNext: findNext,
|
||||
onPrev: findPrev,
|
||||
onDismiss: {
|
||||
withAnimation { showFindBar = false }
|
||||
searchQuery = ""
|
||||
lastMatchRange = NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
Divider()
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
KeyToolbarButton(icon: "increase.indent", label: "Indent") { bridge.activeTextView?.shiftRight() }
|
||||
KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { bridge.activeTextView?.shiftLeft() }
|
||||
KeyToolbarButton(icon: "increase.indent", label: "Indent") { indent(increase: true) }
|
||||
KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { indent(increase: false) }
|
||||
KeyToolbarDivider()
|
||||
KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() }
|
||||
KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() }
|
||||
KeyToolbarDivider()
|
||||
KeyToolbarText("(") { insertPair("(", ")") }
|
||||
KeyToolbarText("[") { insertPair("[", "]") }
|
||||
|
|
@ -38,86 +57,233 @@ struct EditorKeyboardToolbar: View {
|
|||
KeyToolbarButton(icon: "arrow.left", label: "Left") { moveCursor(-1) }
|
||||
KeyToolbarButton(icon: "arrow.right", label: "Right") { moveCursor(+1) }
|
||||
KeyToolbarDivider()
|
||||
KeyToolbarButton(icon: "magnifyingglass", label: "Find") { withAnimation { showFindBar.toggle() } }
|
||||
KeyToolbarButton(icon: "magnifyingglass", label: "Find") {
|
||||
withAnimation { showFindBar.toggle() }
|
||||
}
|
||||
Spacer()
|
||||
KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") {
|
||||
bridge.activeTextView?.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
.onAppear {
|
||||
bridge.onFindNext = { findNext() }
|
||||
bridge.onFindPrev = { findPrev() }
|
||||
}
|
||||
}
|
||||
|
||||
private func insert(_ t: String) { bridge.activeTextView?.insertText(t) }
|
||||
private func insertPair(_ l: String, _ r: String) { bridge.activeTextView?.insertText(l+r); moveCursor(-1) }
|
||||
private func moveCursor(_ offset: Int) {
|
||||
guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return }
|
||||
tv.selectedRange = NSRange(location: max(0, sel.location + offset), length: 0)
|
||||
// MARK: - Text Operations
|
||||
|
||||
private func insert(_ text: String) {
|
||||
bridge.activeTextView?.insertText(text)
|
||||
}
|
||||
private func toggleComment() {
|
||||
guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return }
|
||||
|
||||
private func insertPair(_ open: String, _ close: String) {
|
||||
bridge.activeTextView?.insertText(open + close)
|
||||
moveCursor(-1)
|
||||
}
|
||||
|
||||
private func moveCursor(_ offset: Int) {
|
||||
guard let tv = bridge.activeTextView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let newLocation = max(0, sel.location + offset)
|
||||
tv.selectedRange = NSRange(location: newLocation, length: 0)
|
||||
}
|
||||
|
||||
private func indent(increase: Bool) {
|
||||
guard let tv = bridge.activeTextView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString
|
||||
let lr = text.lineRange(for: sel)
|
||||
let line = text.substring(with: lr)
|
||||
let lineRange = text.lineRange(for: sel)
|
||||
let line = text.substring(with: lineRange)
|
||||
let modified: String
|
||||
if increase {
|
||||
modified = " " + line
|
||||
} else {
|
||||
if line.hasPrefix(" ") {
|
||||
modified = String(line.dropFirst(4))
|
||||
} else if line.hasPrefix("\t") {
|
||||
modified = String(line.dropFirst(1))
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
tv.replace(lineRange, withText: modified)
|
||||
}
|
||||
|
||||
private func toggleComment() {
|
||||
guard let tv = bridge.activeTextView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString
|
||||
let lineRange = text.lineRange(for: sel)
|
||||
let line = text.substring(with: lineRange)
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
|
||||
let newLine: String
|
||||
if trimmed.hasPrefix("// ") { newLine = line.replacingFirstOccurrence(of: "// ", with: "") }
|
||||
else if trimmed.hasPrefix("//") { newLine = line.replacingFirstOccurrence(of: "//", with: "") }
|
||||
else { newLine = indent + "// " + line.dropFirst(indent.count) }
|
||||
tv.replace(lr, withText: newLine)
|
||||
if trimmed.hasPrefix("// ") {
|
||||
newLine = line.replacingFirstOccurrence(of: "// ", with: "")
|
||||
} else if trimmed.hasPrefix("//") {
|
||||
newLine = line.replacingFirstOccurrence(of: "//", with: "")
|
||||
} else {
|
||||
newLine = indent + "// " + line.dropFirst(indent.count)
|
||||
}
|
||||
tv.replace(lineRange, withText: newLine)
|
||||
}
|
||||
|
||||
// MARK: - Find (manual NSString search — no Runestone-specific API)
|
||||
|
||||
private func findNext() {
|
||||
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
|
||||
tv.selectNextOccurrence(matching: SearchOptions(query: searchQuery))
|
||||
let fullText = tv.text as NSString
|
||||
let totalLength = fullText.length
|
||||
|
||||
// Search from just after the last match, wrapping if needed
|
||||
let searchStart: Int
|
||||
if lastMatchRange.location != NSNotFound {
|
||||
searchStart = min(lastMatchRange.location + lastMatchRange.length, totalLength)
|
||||
} else {
|
||||
searchStart = 0
|
||||
}
|
||||
|
||||
// Try from searchStart to end
|
||||
var found = fullText.range(of: searchQuery,
|
||||
options: .caseInsensitive,
|
||||
range: NSRange(location: searchStart,
|
||||
length: totalLength - searchStart))
|
||||
|
||||
// Wrap around if not found
|
||||
if found.location == NSNotFound && searchStart > 0 {
|
||||
found = fullText.range(of: searchQuery,
|
||||
options: .caseInsensitive,
|
||||
range: NSRange(location: 0, length: searchStart))
|
||||
}
|
||||
|
||||
if found.location != NSNotFound {
|
||||
lastMatchRange = found
|
||||
tv.selectedRange = found
|
||||
tv.scrollRangeToVisible(found)
|
||||
}
|
||||
}
|
||||
|
||||
private func findPrev() {
|
||||
guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return }
|
||||
tv.selectPreviousOccurrence(matching: SearchOptions(query: searchQuery))
|
||||
let fullText = tv.text as NSString
|
||||
let totalLength = fullText.length
|
||||
|
||||
// Search backwards from just before the last match
|
||||
let searchEnd: Int
|
||||
if lastMatchRange.location != NSNotFound {
|
||||
searchEnd = lastMatchRange.location
|
||||
} else {
|
||||
searchEnd = totalLength
|
||||
}
|
||||
|
||||
// Try backwards from searchEnd to start
|
||||
var found = fullText.range(of: searchQuery,
|
||||
options: [.caseInsensitive, .backwards],
|
||||
range: NSRange(location: 0, length: searchEnd))
|
||||
|
||||
// Wrap around to end
|
||||
if found.location == NSNotFound && searchEnd < totalLength {
|
||||
found = fullText.range(of: searchQuery,
|
||||
options: [.caseInsensitive, .backwards],
|
||||
range: NSRange(location: searchEnd, length: totalLength - searchEnd))
|
||||
}
|
||||
|
||||
if found.location != NSNotFound {
|
||||
lastMatchRange = found
|
||||
tv.selectedRange = found
|
||||
tv.scrollRangeToVisible(found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline Find Bar
|
||||
|
||||
struct InlineFindBar: View {
|
||||
@Binding var query: String; @Binding var isRegex: Bool
|
||||
var onNext: () -> Void; var onPrev: () -> Void; var onDismiss: () -> Void
|
||||
@Binding var query: String
|
||||
var onNext: () -> Void
|
||||
var onPrev: () -> Void
|
||||
var onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("Find…", text: $query).textFieldStyle(.plain)
|
||||
.font(.system(.body, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none)
|
||||
.onSubmit(onNext)
|
||||
Toggle(isOn: $isRegex) { Image(systemName: "ellipsis.curlybraces") }
|
||||
.toggleStyle(.button).buttonStyle(.bordered).tint(isRegex ? .accentColor : .secondary)
|
||||
Button(action: onPrev) { Image(systemName: "chevron.up") }.buttonStyle(.bordered).disabled(query.isEmpty)
|
||||
Button(action: onNext) { Image(systemName: "chevron.down") }.buttonStyle(.bordered).disabled(query.isEmpty)
|
||||
Button(action: onDismiss) { Image(systemName: "xmark") }.buttonStyle(.bordered)
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Find...", text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.onSubmit { onNext() }
|
||||
Button(action: onPrev) {
|
||||
Image(systemName: "chevron.up")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(query.isEmpty)
|
||||
Button(action: onNext) {
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(query.isEmpty)
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Sub-components
|
||||
|
||||
struct KeyToolbarButton: View {
|
||||
let icon: String; let label: String; let action: () -> Void
|
||||
let icon: String
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) { Image(systemName: icon).frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) }
|
||||
.buttonStyle(.plain).foregroundStyle(.primary).help(label)
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
.help(label)
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyToolbarText: View {
|
||||
let text: String; let action: () -> Void
|
||||
init(_ text: String, action: @escaping () -> Void) { self.text = text; self.action = action }
|
||||
let text: String
|
||||
let action: () -> Void
|
||||
|
||||
init(_ text: String, action: @escaping () -> Void) {
|
||||
self.text = text
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.system(.callout, design: .monospaced).bold())
|
||||
.frame(minWidth: 32, minHeight: 32).contentShape(Rectangle())
|
||||
Text(text)
|
||||
.font(.system(.callout, design: .monospaced).bold())
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).foregroundStyle(.primary)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyToolbarDivider: View {
|
||||
var body: some View { Divider().frame(height: 20).padding(.horizontal, 2) }
|
||||
var body: some View {
|
||||
Divider()
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,15 +40,18 @@ final class EditorState: ObservableObject {
|
|||
set { themeRawValue = newValue.rawValue }
|
||||
}
|
||||
|
||||
private var autosaveTask: Task<Void, Never>?
|
||||
// Keyed by tab UUID — each tab has its own debounce so edits on one
|
||||
// tab don't cancel pending saves on another.
|
||||
private var autosaveTasks: [UUID: Task<Void, Never>] = [:]
|
||||
var onSaveRequest: ((String, String) async -> Void)?
|
||||
var onTabClosed: ((String) -> Void)?
|
||||
|
||||
// MARK: - Active Theme
|
||||
|
||||
var activeTheme: any Theme {
|
||||
switch selectedTheme {
|
||||
case .xcodeDefault: return PadXcodeTheme()
|
||||
case .xcodeLight: return PadXcodeLightTheme()
|
||||
case .xcodeDefault: return PadXcodeTheme(fontSize: fontSize)
|
||||
case .xcodeLight: return PadXcodeLightTheme(fontSize: fontSize)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,10 +74,14 @@ final class EditorState: ObservableObject {
|
|||
|
||||
func closeTab(id: UUID) {
|
||||
guard let idx = tabs.firstIndex(where: { $0.id == id }) else { return }
|
||||
let closedPath = tabs[idx].path
|
||||
autosaveTasks[id]?.cancel()
|
||||
autosaveTasks.removeValue(forKey: id)
|
||||
tabs.remove(at: idx)
|
||||
if activeTabId == id {
|
||||
activeTabId = tabs.isEmpty ? nil : tabs[min(idx, tabs.count - 1)].id
|
||||
}
|
||||
onTabClosed?(closedPath)
|
||||
}
|
||||
|
||||
func updateContent(tabId: UUID, newContent: String) {
|
||||
|
|
@ -99,8 +106,8 @@ final class EditorState: ObservableObject {
|
|||
// MARK: - Autosave
|
||||
|
||||
private func scheduleAutosave(tabId: UUID) {
|
||||
autosaveTask?.cancel()
|
||||
autosaveTask = Task { [weak self] in
|
||||
autosaveTasks[tabId]?.cancel()
|
||||
autosaveTasks[tabId] = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
guard !Task.isCancelled,
|
||||
let self,
|
||||
|
|
@ -109,9 +116,15 @@ final class EditorState: ObservableObject {
|
|||
await self.onSaveRequest?(tab.path, tab.content)
|
||||
if let idx = self.tabs.firstIndex(where: { $0.id == tabId }) {
|
||||
self.tabs[idx].isDirty = false
|
||||
self.autosaveTasks.removeValue(forKey: tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func markSaved(tabId: UUID) {
|
||||
guard let idx = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
||||
tabs[idx].isDirty = false
|
||||
}
|
||||
var dirtyTabNames: [String] { tabs.filter { $0.isDirty }.map { $0.fileName } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import SwiftUI
|
|||
|
||||
struct EditorTabBar: View {
|
||||
@ObservedObject var editorState: EditorState
|
||||
@Environment(\.requestTabClose) private var requestTabClose
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
|
|
@ -11,7 +12,9 @@ struct EditorTabBar: View {
|
|||
tab: tab,
|
||||
isActive: tab.id == editorState.activeTabId,
|
||||
onSelect: { editorState.activeTabId = tab.id },
|
||||
onClose: { editorState.closeTab(id: tab.id) }
|
||||
// Route through requestTabClose so dirty tabs show the
|
||||
// unsaved changes alert instead of silently discarding.
|
||||
onClose: { requestTabClose(tab.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,12 +59,24 @@ struct FindAcrossFilesView: View {
|
|||
guard !query.isEmpty, let tree = fileTree else { return }
|
||||
isSearching = true; results = []
|
||||
let paths = collectPaths(from: tree).filter { isSearchable($0) }
|
||||
|
||||
// Step 1: Fetch all file contents on main actor (BuildService is @MainActor).
|
||||
// Do this serially but efficiently — network is the bottleneck anyway.
|
||||
var contentMap: [String: String] = [:]
|
||||
for path in paths {
|
||||
if let content = await buildService.fetchFileContent(path: path) {
|
||||
contentMap[path] = content
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Run the regex search concurrently across all content strings.
|
||||
// searchContent() is nonisolated so this is genuinely parallel.
|
||||
var found: [FileResult] = []
|
||||
let capturedQuery = query
|
||||
await withTaskGroup(of: FileResult?.self) { group in
|
||||
for path in paths {
|
||||
for (path, content) in contentMap {
|
||||
group.addTask {
|
||||
guard let content = await buildService.fetchFileContent(path: path) else { return nil }
|
||||
let matches = searchContent(content)
|
||||
let matches = self.searchContent(content, query: capturedQuery)
|
||||
return matches.isEmpty ? nil : FileResult(filePath: path, matches: matches)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +86,7 @@ struct FindAcrossFilesView: View {
|
|||
isSearching = false
|
||||
}
|
||||
|
||||
private func searchContent(_ content: String) -> [FileResult.Match] {
|
||||
nonisolated private func searchContent(_ content: String, query: String) -> [FileResult.Match] {
|
||||
let lines = content.components(separatedBy: "\n"); var matches: [FileResult.Match] = []; var offset = 0
|
||||
let pattern = NSRegularExpression.escapedPattern(for: query)
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return [] }
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ struct FindInFileView: View {
|
|||
}
|
||||
offset += line.count + 1
|
||||
}
|
||||
results = found; if !found.isEmpty { selected = 0; onNavigate(found[0].range) }
|
||||
results = found; if !found.isEmpty { selected = 0 }
|
||||
}
|
||||
|
||||
private func navigateNext() { guard !results.isEmpty else { return }; selected = (selected+1) % results.count; onNavigate(results[selected].range) }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
// GutterAnnotationView.swift
|
||||
// DiagnosticRange is defined in Shared/SharedModels.swift
|
||||
// No Identifiable conformance extension needed here.
|
||||
import SwiftUI
|
||||
import Runestone
|
||||
|
||||
// MARK: - Annotation Model (local to this file — wraps DiagnosticRange with position)
|
||||
// MARK: - Annotation Model
|
||||
|
||||
struct GutterAnnotation: Identifiable {
|
||||
let id = UUID()
|
||||
|
|
@ -41,6 +38,10 @@ struct GutterAnnotationOverlay: View {
|
|||
}
|
||||
.onAppear { computePositions() }
|
||||
.onChange(of: diagnostics) { _, _ in computePositions() }
|
||||
// Recompute when the text view scrolls so annotations track the correct line positions.
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIScrollView.didScrollNotification)) { note in
|
||||
if (note.object as? UIScrollView) === textView { computePositions() }
|
||||
}
|
||||
.popover(item: $selected) { annotation in
|
||||
DiagnosticPopover(annotation: annotation)
|
||||
}
|
||||
|
|
@ -69,17 +70,19 @@ struct GutterAnnotationOverlay: View {
|
|||
private func computePositions() {
|
||||
guard let tv = textView else { return }
|
||||
var result: [PositionedAnnotation] = []
|
||||
|
||||
for diag in diagnostics {
|
||||
let ann = GutterAnnotation(line: diag.line, severity: diag.severity, message: diag.message)
|
||||
guard let range = tv.range(atLine: ann.line, column: 0),
|
||||
let rect = tv.caretRect(for: range.location) else { continue }
|
||||
let y = rect.midY - tv.contentOffset.y
|
||||
if y > 0 && y < tv.bounds.height {
|
||||
result.append(PositionedAnnotation(annotation: ann, y: y))
|
||||
}
|
||||
// caretRect(for:) on Runestone.TextView takes UITextPosition, not Int.
|
||||
// Compute Y from font metrics instead — reliable for a code editor
|
||||
// where all lines have equal height.
|
||||
let font = tv.theme.font
|
||||
let lineHeight = ceil(font.lineHeight * tv.lineHeightMultiplier)
|
||||
let y = CGFloat(ann.line - 1) * lineHeight
|
||||
+ tv.textContainerInset.top
|
||||
- tv.contentOffset.y
|
||||
guard y > 0 && y < tv.bounds.height else { continue }
|
||||
result.append(PositionedAnnotation(annotation: ann, y: y))
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.15)) { annotations = result }
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +111,8 @@ struct DiagnosticPopover: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex colour helper (local)
|
||||
// MARK: - Hex colour helper (private to this file)
|
||||
|
||||
private extension Color {
|
||||
init(hex: String) {
|
||||
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
|
|||
|
|
@ -87,18 +87,33 @@ final class EditorCommandHandler: UIViewController {
|
|||
@objc func cmdUnindent() { textView?.shiftLeft() }
|
||||
@objc func cmdFind() { onFind?() }
|
||||
@objc func cmdFindProject(){ onFindInProject?() }
|
||||
@objc func cmdFindNext() {}
|
||||
@objc func cmdFindPrev() {}
|
||||
@objc func cmdFindNext() {
|
||||
NotificationCenter.default.post(
|
||||
name: .editorFindNext, object: nil)
|
||||
}
|
||||
@objc func cmdFindPrev() {
|
||||
NotificationCenter.default.post(
|
||||
name: .editorFindPrev, object: nil)
|
||||
}
|
||||
@objc func cmdJumpToLine() { onJumpToLine?() }
|
||||
@objc func cmdBuild() { onBuild?() }
|
||||
@objc func cmdRun() { onRun?() }
|
||||
@objc func cmdNavigator() { onToggleNavigator?() }
|
||||
@objc func cmdConsole() { onToggleConsole?() }
|
||||
@objc func cmdFontBigger() {}
|
||||
@objc func cmdFontSmaller(){}
|
||||
@objc func cmdFontBigger() {
|
||||
NotificationCenter.default.post(
|
||||
name: .editorFontSizeChange, object: nil,
|
||||
userInfo: ["delta": 1.0])
|
||||
}
|
||||
@objc func cmdFontSmaller() {
|
||||
NotificationCenter.default.post(
|
||||
name: .editorFontSizeChange, object: nil,
|
||||
userInfo: ["delta": -1.0])
|
||||
}
|
||||
|
||||
@objc func cmdComment() {
|
||||
guard let tv = textView, let sel = tv.selectedRange else { return }
|
||||
guard let tv = textView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString; let lr = text.lineRange(for: sel)
|
||||
let line = text.substring(with: lr); let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
|
||||
|
|
@ -110,7 +125,8 @@ final class EditorCommandHandler: UIViewController {
|
|||
}
|
||||
|
||||
@objc func cmdMoveUp() {
|
||||
guard let tv = textView, let sel = tv.selectedRange else { return }
|
||||
guard let tv = textView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString; let cur = text.lineRange(for: sel)
|
||||
guard cur.location > 0 else { return }
|
||||
let prev = text.lineRange(for: NSRange(location: cur.location - 1, length: 0))
|
||||
|
|
@ -120,7 +136,8 @@ final class EditorCommandHandler: UIViewController {
|
|||
}
|
||||
|
||||
@objc func cmdMoveDown() {
|
||||
guard let tv = textView, let sel = tv.selectedRange else { return }
|
||||
guard let tv = textView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString; let cur = text.lineRange(for: sel)
|
||||
let end = cur.location + cur.length; guard end < text.length else { return }
|
||||
let next = text.lineRange(for: NSRange(location: end, length: 0))
|
||||
|
|
@ -130,14 +147,16 @@ final class EditorCommandHandler: UIViewController {
|
|||
}
|
||||
|
||||
@objc func cmdDuplicate() {
|
||||
guard let tv = textView, let sel = tv.selectedRange else { return }
|
||||
guard let tv = textView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString; let lr = text.lineRange(for: sel)
|
||||
let line = text.substring(with: lr)
|
||||
tv.replace(NSRange(location: lr.location + lr.length, length: 0), withText: line)
|
||||
}
|
||||
|
||||
@objc func cmdDeleteLine() {
|
||||
guard let tv = textView, let sel = tv.selectedRange else { return }
|
||||
guard let tv = textView else { return }
|
||||
let sel = tv.selectedRange
|
||||
let text = tv.text as NSString
|
||||
tv.replace(text.lineRange(for: sel), withText: "")
|
||||
}
|
||||
|
|
@ -153,3 +172,44 @@ final class EditorCommandHandler: UIViewController {
|
|||
tv.selectedRange = end; tv.scrollRangeToVisible(end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SwiftUI wrapper
|
||||
|
||||
struct EditorCommandHandlerView: UIViewControllerRepresentable {
|
||||
@Binding var textViewRef: Runestone.TextView?
|
||||
var onSave: (() -> Void)?
|
||||
var onBuild: (() -> Void)?
|
||||
var onRun: (() -> Void)?
|
||||
var onToggleNavigator: (() -> Void)?
|
||||
var onToggleConsole: (() -> Void)?
|
||||
var onFind: (() -> Void)?
|
||||
var onJumpToLine: (() -> Void)?
|
||||
var onFindInProject: (() -> Void)?
|
||||
|
||||
func makeUIViewController(context: Context) -> EditorCommandHandler {
|
||||
let vc = EditorCommandHandler()
|
||||
vc.textView = textViewRef
|
||||
vc.onSave = onSave
|
||||
vc.onBuild = onBuild
|
||||
vc.onRun = onRun
|
||||
vc.onToggleNavigator = onToggleNavigator
|
||||
vc.onToggleConsole = onToggleConsole
|
||||
vc.onFind = onFind
|
||||
vc.onJumpToLine = onJumpToLine
|
||||
vc.onFindInProject = onFindInProject
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: EditorCommandHandler, context: Context) {
|
||||
vc.textView = textViewRef
|
||||
vc.onSave = onSave
|
||||
vc.onBuild = onBuild
|
||||
vc.onRun = onRun
|
||||
vc.onToggleNavigator = onToggleNavigator
|
||||
vc.onToggleConsole = onToggleConsole
|
||||
vc.onFind = onFind
|
||||
vc.onJumpToLine = onJumpToLine
|
||||
vc.onFindInProject = onFindInProject
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ import SwiftUI
|
|||
import UIKit
|
||||
import Runestone
|
||||
|
||||
// MARK: - CharacterPair concrete implementation
|
||||
// CharacterPair is a protocol in Runestone — no concrete BasicCharacterPair exists.
|
||||
|
||||
private struct EditorCharacterPair: CharacterPair {
|
||||
let leading: String
|
||||
let trailing: String
|
||||
}
|
||||
|
||||
// MARK: - RunestoneEditorView
|
||||
|
||||
struct RunestoneEditorView: UIViewRepresentable {
|
||||
|
||||
@Binding var content: String
|
||||
|
|
@ -20,7 +30,8 @@ struct RunestoneEditorView: UIViewRepresentable {
|
|||
|
||||
func makeUIView(context: Context) -> Runestone.TextView {
|
||||
let tv = Runestone.TextView()
|
||||
configureAppearance(tv); configureEditing(tv)
|
||||
configureAppearance(tv)
|
||||
configureEditing(tv)
|
||||
tv.editorDelegate = context.coordinator
|
||||
completionController.attach(to: tv)
|
||||
DispatchQueue.main.async { textViewRef = tv }
|
||||
|
|
@ -29,19 +40,33 @@ struct RunestoneEditorView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func updateUIView(_ tv: Runestone.TextView, context: Context) {
|
||||
if tv.text != content && !context.coordinator.isEditing { loadState(into: tv) }
|
||||
// Reload state if content changed externally OR if theme changed (font size, theme switch).
|
||||
let themeChanged = type(of: tv.theme) != type(of: theme) ||
|
||||
tv.theme.font.pointSize != theme.font.pointSize
|
||||
if (tv.text != content || themeChanged) && !context.coordinator.isEditing {
|
||||
loadState(into: tv)
|
||||
}
|
||||
tv.showLineNumbers = showLineNumbers
|
||||
tv.isLineWrappingEnabled = lineWrapping
|
||||
tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth)
|
||||
tv.indentStrategy = indentWidth == 0
|
||||
? .tab(length: 4)
|
||||
: .space(length: indentWidth)
|
||||
applyDiagnostics(to: tv)
|
||||
}
|
||||
|
||||
private func loadState(into tv: Runestone.TextView) {
|
||||
let lang = LanguageDetector.language(for: filePath)
|
||||
let capturedContent = content; let capturedTheme = theme
|
||||
let lang = LanguageDetector.language(for: filePath)
|
||||
let capturedText = content
|
||||
let capturedTheme = theme
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let state = TextViewState(text: capturedContent, theme: capturedTheme,
|
||||
language: lang.map { TreeSitterLanguageMode(language: $0) })
|
||||
// TextViewState init takes TreeSitterLanguage (non-optional).
|
||||
// Use two separate inits: one with language, one without (plain text).
|
||||
let state: TextViewState
|
||||
if let lang = lang {
|
||||
state = TextViewState(text: capturedText, theme: capturedTheme, language: lang)
|
||||
} else {
|
||||
state = TextViewState(text: capturedText, theme: capturedTheme)
|
||||
}
|
||||
await MainActor.run { tv.setState(state) }
|
||||
}
|
||||
}
|
||||
|
|
@ -49,43 +74,66 @@ struct RunestoneEditorView: UIViewRepresentable {
|
|||
private func configureAppearance(_ tv: Runestone.TextView) {
|
||||
tv.backgroundColor = UIColor(hex: "#1E1E1E")
|
||||
tv.showLineNumbers = showLineNumbers
|
||||
tv.showSpaces = false; tv.showTabs = false; tv.showLineBreaks = false
|
||||
tv.showSpaces = false
|
||||
tv.showTabs = false
|
||||
tv.showLineBreaks = false
|
||||
tv.isLineWrappingEnabled = lineWrapping
|
||||
tv.highlightSelectedLine = true
|
||||
tv.lineHeightMultiplier = 1.4; tv.kern = 0.3
|
||||
// highlightSelectedLine was replaced by lineSelectionDisplayType.
|
||||
// .line highlights the active line in the text area only.
|
||||
tv.lineSelectionDisplayType = .line
|
||||
tv.lineHeightMultiplier = 1.4
|
||||
tv.kern = 0.3
|
||||
tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 120, right: 0)
|
||||
tv.verticalOverscrollFactor = 0.5
|
||||
}
|
||||
|
||||
private func configureEditing(_ tv: Runestone.TextView) {
|
||||
tv.isEditable = true; tv.autocorrectionType = .no; tv.autocapitalizationType = .none
|
||||
tv.smartDashesType = .no; tv.smartQuotesType = .no
|
||||
tv.smartInsertDeleteType = .no; tv.spellCheckingType = .no
|
||||
tv.isEditable = true
|
||||
tv.autocorrectionType = .no
|
||||
tv.autocapitalizationType = .none
|
||||
tv.smartDashesType = .no
|
||||
tv.smartQuotesType = .no
|
||||
tv.smartInsertDeleteType = .no
|
||||
tv.spellCheckingType = .no
|
||||
tv.characterPairs = [
|
||||
BasicCharacterPair(leading: "(", trailing: ")"),
|
||||
BasicCharacterPair(leading: "[", trailing: "]"),
|
||||
BasicCharacterPair(leading: "{", trailing: "}"),
|
||||
BasicCharacterPair(leading: "\"", trailing: "\""),
|
||||
EditorCharacterPair(leading: "(", trailing: ")"),
|
||||
EditorCharacterPair(leading: "[", trailing: "]"),
|
||||
EditorCharacterPair(leading: "{", trailing: "}"),
|
||||
EditorCharacterPair(leading: "\"", trailing: "\""),
|
||||
]
|
||||
tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent
|
||||
tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth)
|
||||
tv.indentStrategy = indentWidth == 0
|
||||
? .tab(length: 4)
|
||||
: .space(length: indentWidth)
|
||||
}
|
||||
|
||||
private func applyDiagnostics(to tv: Runestone.TextView) {
|
||||
guard !diagnosticRanges.isEmpty else { tv.setHighlightedRanges([]); return }
|
||||
let highlights: [HighlightedRange] = diagnosticRanges.compactMap { diag in
|
||||
guard let range = tv.range(atLine: diag.line, column: diag.column) else { return nil }
|
||||
let color: UIColor = diag.severity == .error ? UIColor(hex: "#F44747") : UIColor(hex: "#CCA700")
|
||||
return HighlightedRange(range: range, color: color, cornerRadius: 0, underlineStyle: .single)
|
||||
guard !diagnosticRanges.isEmpty else {
|
||||
tv.highlightedRanges = []
|
||||
return
|
||||
}
|
||||
tv.setHighlightedRanges(highlights)
|
||||
var highlights: [HighlightedRange] = []
|
||||
for diag in diagnosticRanges {
|
||||
// TextLocation is 0-indexed; compiler diagnostics are 1-indexed.
|
||||
let textLoc = TextLocation(lineNumber: diag.line - 1, column: diag.column - 1)
|
||||
guard let charOffset = tv.location(at: textLoc) else { continue }
|
||||
let nsRange = NSRange(location: charOffset, length: max(1, diag.length))
|
||||
let color: UIColor = diag.severity == .error
|
||||
? UIColor(hex: "#F44747")
|
||||
: UIColor(hex: "#CCA700")
|
||||
// HighlightedRange only takes range and color — no cornerRadius/underlineStyle.
|
||||
highlights.append(HighlightedRange(range: nsRange, color: color))
|
||||
}
|
||||
tv.highlightedRanges = highlights
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
final class Coordinator: NSObject, TextViewDelegate {
|
||||
var parent: RunestoneEditorView
|
||||
var isEditing = false
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, @preconcurrency TextViewDelegate {
|
||||
var parent: RunestoneEditorView
|
||||
var isEditing: Bool = false
|
||||
|
||||
init(_ parent: RunestoneEditorView) { self.parent = parent }
|
||||
|
||||
func textViewDidChange(_ tv: Runestone.TextView) {
|
||||
|
|
@ -97,12 +145,20 @@ struct RunestoneEditorView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func textViewDidChangeSelection(_ tv: Runestone.TextView) {
|
||||
guard let sel = tv.selectedRange, let loc = tv.textLocation(at: sel.location) else { return }
|
||||
parent.onCursorPositionChange?(loc.lineNumber, loc.column)
|
||||
let sel = tv.selectedRange
|
||||
if let loc = tv.textLocation(at: sel.location) {
|
||||
parent.onCursorPositionChange?(loc.lineNumber + 1, loc.column + 1)
|
||||
}
|
||||
if !isEditing { parent.completionController.dismiss() }
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ tv: Runestone.TextView) { isEditing = true }
|
||||
func textViewDidEndEditing(_ tv: Runestone.TextView) { isEditing = false; parent.completionController.dismiss() }
|
||||
func textViewDidBeginEditing(_ tv: Runestone.TextView) {
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ tv: Runestone.TextView) {
|
||||
isEditing = false
|
||||
parent.completionController.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ struct GitPanel: View {
|
|||
Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch").font(.caption).foregroundStyle(.secondary)
|
||||
Text(branch.name).font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(.accentColor) }
|
||||
if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) }
|
||||
}
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
|
|
@ -213,12 +213,12 @@ struct GitPanel: View {
|
|||
label: {
|
||||
HStack {
|
||||
Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch")
|
||||
.font(.caption).foregroundStyle(branch.name == gitStore.currentBranch ? .accentColor : .secondary)
|
||||
.font(.caption).foregroundStyle(branch.name == gitStore.currentBranch ? Color.accentColor : .secondary)
|
||||
Text(branch.name).font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(branch.name == gitStore.currentBranch ? .accentColor : .primary)
|
||||
.foregroundStyle(branch.name == gitStore.currentBranch ? Color.accentColor : .primary)
|
||||
.bold(branch.name == gitStore.currentBranch)
|
||||
Spacer()
|
||||
if branch.name == gitStore.currentBranch { Text("current").font(.caption2).foregroundStyle(.accentColor) }
|
||||
if branch.name == gitStore.currentBranch { Text("current").font(.caption2).foregroundStyle(Color.accentColor) }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import UIKit
|
|||
/// PadXcode only needs the repo display name.
|
||||
final class GitService {
|
||||
|
||||
private let apiKey: String
|
||||
// Read from UserDefaults at call time so changes saved in Settings
|
||||
// are reflected immediately without requiring an app restart.
|
||||
private var apiKey: String {
|
||||
UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
|
||||
}
|
||||
private let callbackBase = "padxcode://git-callback"
|
||||
|
||||
init(apiKey: String) { self.apiKey = apiKey }
|
||||
init() {}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
|
|
@ -108,7 +112,13 @@ final class GitService {
|
|||
}
|
||||
for (k, v) in params { items.append(.init(name: k, value: v)) }
|
||||
c.queryItems = items
|
||||
return c.url!
|
||||
// URL construction from URLComponents with a valid scheme cannot fail,
|
||||
// but guard defensively rather than force-unwrap.
|
||||
guard let url = c.url else {
|
||||
assertionFailure("Failed to build Working Copy URL for command: \(command)")
|
||||
return URL(string: "working-copy://")!
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func open(_ url: URL) {
|
||||
|
|
|
|||
30
Info.plist
30
Info.plist
|
|
@ -3,14 +3,22 @@
|
|||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>PadXcode</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PadXcode</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ca.dallasgroot.PadXcode</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -47,5 +55,21 @@
|
|||
</dict>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- PadXcode connects to a local Mac daemon over Tailscale (HTTP).
|
||||
This is a developer tool — ATS is intentionally relaxed. -->
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -30,15 +30,28 @@ struct ContentView: View {
|
|||
|
||||
enum LeftPanel { case files, git }
|
||||
|
||||
// BuildService and FileSyncPipeline are initialised lazily in onAppear
|
||||
// once the @EnvironmentObject instances are available — NOT in init() where
|
||||
// they haven't been injected yet. We use placeholder objects here and replace
|
||||
// them in initialLoad().
|
||||
//
|
||||
// The real fix: move StateObject creation out of init entirely and drive it
|
||||
// from the environment. Since @StateObject requires init-time wrappedValue,
|
||||
// we pass the environment config via a separate path using @EnvironmentObject
|
||||
// indirection resolved in task{}.
|
||||
|
||||
init(gitService: GitService) {
|
||||
self.gitService = gitService
|
||||
let config = DaemonConfiguration()
|
||||
let store = GitStore()
|
||||
let bs = BuildService(config: config)
|
||||
// Temporary placeholder config — replaced with the real environment
|
||||
// DaemonConfiguration in initialLoad() via buildService.updateConfig().
|
||||
// This ensures BuildService always uses the IP/port the user configured.
|
||||
let placeholderConfig = DaemonConfiguration()
|
||||
let placeholderStore = GitStore()
|
||||
let bs = BuildService(config: placeholderConfig)
|
||||
_buildService = StateObject(wrappedValue: bs)
|
||||
_syncPipeline = StateObject(wrappedValue: FileSyncPipeline(
|
||||
buildService: bs, gitService: gitService,
|
||||
gitStore: store, config: config))
|
||||
gitStore: placeholderStore))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -57,6 +70,7 @@ struct ContentView: View {
|
|||
} detail: {
|
||||
rightColumn
|
||||
}
|
||||
.unsavedChangesGuard(editorState: editorState, buildService: buildService)
|
||||
.task { await initialLoad() }
|
||||
.onChange(of: buildService.consoleLines) { _, lines in applyDiagnostics(from: lines) }
|
||||
.onChange(of: lspClient.diagnostics) { _, diags in
|
||||
|
|
@ -156,7 +170,8 @@ struct ContentView: View {
|
|||
buildService: buildService,
|
||||
onBuild: { triggerBuild("build") },
|
||||
onRun: { triggerBuild("buildAndRun") },
|
||||
onRefreshDevices: { Task { await buildService.fetchDevices() } }
|
||||
onRefreshDevices: { Task { await buildService.fetchDevices() } },
|
||||
onFindInProject: { showFindInProject = true }
|
||||
)
|
||||
Divider()
|
||||
if !editorState.tabs.isEmpty { EditorTabBar(editorState: editorState) }
|
||||
|
|
@ -182,9 +197,6 @@ struct ContentView: View {
|
|||
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."))
|
||||
|
|
@ -193,7 +205,7 @@ struct ContentView: View {
|
|||
|
||||
if consoleVisible {
|
||||
ConsoleResizeHandle(height: $consoleHeight); Divider()
|
||||
EnhancedConsoleView(lines: buildService.consoleLines).frame(height: consoleHeight)
|
||||
EnhancedConsoleView(lines: buildService.consoleLines, onClear: { buildService.clearConsole() }).frame(height: consoleHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -202,16 +214,28 @@ struct ContentView: View {
|
|||
|
||||
private func initialLoad() async {
|
||||
gitStore.checkInstallation()
|
||||
// Rewire BuildService and LSPClient to use the real environment DaemonConfiguration
|
||||
// (the one the user edits in Settings), not the placeholder created in init().
|
||||
buildService.rewireConfig(daemonConfig, gitStore: gitStore)
|
||||
syncPipeline.rewireGitStore(gitStore)
|
||||
lspClient.config = daemonConfig
|
||||
await buildService.fetchDevices()
|
||||
editorState.onSaveRequest = { path, content in await buildService.saveFile(path: path, content: content) }
|
||||
editorState.onSaveRequest = { [weak syncPipeline] path, content in
|
||||
await syncPipeline?.saveNow(path: path, content: content)
|
||||
}
|
||||
editorState.onTabClosed = { [weak lspClient, weak daemonConfig] path in
|
||||
guard let cfg = daemonConfig, cfg.lspEnabled else { return }
|
||||
lspClient?.didClose(filePath: path)
|
||||
}
|
||||
if let last = projectStore.projects.first { await loadProject(last) }
|
||||
}
|
||||
|
||||
private func loadProject(_ project: SavedProject) async {
|
||||
activeProject = project
|
||||
let lspEnabled = daemonConfig.lspEnabled // capture on main actor before async let
|
||||
async let tree: Void = buildService.fetchFileTree(projectPath: project.rootPath)
|
||||
async let lsp: Void = {
|
||||
if daemonConfig.lspEnabled { await lspClient.connect(projectPath: project.rootPath) }
|
||||
if lspEnabled { await lspClient.connect(projectPath: project.rootPath) }
|
||||
}()
|
||||
await tree; await lsp
|
||||
syncPipeline.registerProject(projectRoot: project.rootPath,
|
||||
|
|
@ -232,7 +256,8 @@ struct ContentView: View {
|
|||
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) {
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
NotificationCenter.default.post(name: .navigateToRange, object: nil, userInfo: ["range": range])
|
||||
}
|
||||
}
|
||||
|
|
@ -240,11 +265,12 @@ struct ContentView: View {
|
|||
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) }
|
||||
let bid = project.bundleIdentifier.isEmpty ? nil : project.bundleIdentifier
|
||||
Task {
|
||||
await buildService.startBuild(
|
||||
projectPath: project.rootPath, scheme: project.scheme,
|
||||
selectedDevice: selectedDevice, action: action,
|
||||
bundleIdentifier: nil)
|
||||
bundleIdentifier: bid)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +366,7 @@ struct ProjectPickerSheet: View {
|
|||
Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(.accentColor) }
|
||||
if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(Color.accentColor) }
|
||||
}
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,12 +61,12 @@ struct EnhancedConsoleView: View {
|
|||
}
|
||||
Spacer()
|
||||
Button { withAnimation { showSearch.toggle(); if !showSearch { searchText = "" } } }
|
||||
label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? .accentColor : .secondary) }
|
||||
label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? Color.accentColor : Color.secondary) }
|
||||
.buttonStyle(.plain)
|
||||
Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") }
|
||||
label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain)
|
||||
Button { isPinned.toggle() }
|
||||
label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? .accentColor : .secondary) }
|
||||
label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? Color.accentColor : Color.secondary) }
|
||||
.buttonStyle(.plain)
|
||||
if let clear = onClear {
|
||||
Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain)
|
||||
|
|
@ -122,13 +122,13 @@ struct EnhancedConsoleView: View {
|
|||
private func pillColor(_ mode: FilterMode) -> Color {
|
||||
switch mode {
|
||||
case .errors: return .red; case .warnings: return .orange
|
||||
case .info: return .blue; case .all: return .accentColor
|
||||
case .info: return .blue; case .all: return Color.accentColor
|
||||
}
|
||||
}
|
||||
private func gutterColor(_ type: ConsoleLine.LineType) -> Color {
|
||||
switch type {
|
||||
case .error: return .red; case .warning: return .orange
|
||||
case .success: return .green; case .info: return .accentColor; default: return .clear
|
||||
case .success: return .green; case .info: return Color.accentColor; default: return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ struct GitAwareNodeRow: View {
|
|||
@State private var showDeleteAlert = false
|
||||
|
||||
private var fileStatus: GitFileStatus? {
|
||||
statusMap.values.first { node.path.hasSuffix($0.path) }
|
||||
statusMap[node.path] ?? statusMap.values.first { node.path.hasSuffix("/" + $0.path) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ final class BuildService: ObservableObject {
|
|||
let historyStore = BuildHistoryStore()
|
||||
let healthMonitor: DaemonHealthMonitor
|
||||
|
||||
let config: DaemonConfiguration
|
||||
var config: DaemonConfiguration
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var buildStartTime: Date?
|
||||
|
|
@ -195,22 +195,35 @@ final class BuildService: ObservableObject {
|
|||
}
|
||||
|
||||
private func receiveLoop() async {
|
||||
guard let task = webSocketTask, task.state == .running else {
|
||||
guard let task = webSocketTask else {
|
||||
isBuilding = false; return
|
||||
}
|
||||
do {
|
||||
let message = try await task.receive()
|
||||
switch message {
|
||||
case .string(let text): handleIncomingLog(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) { handleIncomingLog(text) }
|
||||
@unknown default: break
|
||||
// Use a while loop — not recursion — to avoid unbounded call stack growth
|
||||
// on long builds with many log messages.
|
||||
while task.state == .running {
|
||||
do {
|
||||
let message = try await task.receive()
|
||||
switch message {
|
||||
case .string(let text): handleIncomingLog(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) { handleIncomingLog(text) }
|
||||
@unknown default: break
|
||||
}
|
||||
// Break out if the build completed via status message
|
||||
if !isBuilding { break }
|
||||
} catch {
|
||||
// Socket closed or error — only record failure if still building
|
||||
// (avoids double-recording when build completes normally)
|
||||
if isBuilding {
|
||||
isBuilding = false
|
||||
commitBuildRecord(outcome: .failure)
|
||||
}
|
||||
return
|
||||
}
|
||||
await receiveLoop()
|
||||
} catch {
|
||||
isBuilding = false
|
||||
commitBuildRecord(outcome: .failure)
|
||||
}
|
||||
// Build completed normally — close the WebSocket to free resources.
|
||||
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||
webSocketTask = nil
|
||||
}
|
||||
|
||||
private func handleIncomingLog(_ text: String) {
|
||||
|
|
@ -278,4 +291,15 @@ final class BuildService: ObservableObject {
|
|||
}
|
||||
|
||||
func clearConsole() { consoleLines = [] }
|
||||
|
||||
/// Rewires this service to use the real environment DaemonConfiguration.
|
||||
/// Must be called from ContentView.initialLoad() since @EnvironmentObject
|
||||
/// is unavailable at ContentView.init() time, causing a duplicate object
|
||||
/// to be created in init(). This replaces that placeholder with the real one.
|
||||
func rewireConfig(_ config: DaemonConfiguration, gitStore: GitStore) {
|
||||
self.config = config
|
||||
healthMonitor.stopPolling()
|
||||
healthMonitor.config = config
|
||||
healthMonitor.startPolling()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ final class DaemonHealthMonitor: ObservableObject {
|
|||
var onReconnected: (() async -> Void)?
|
||||
var onDisconnected: (() -> Void)?
|
||||
|
||||
private let config: DaemonConfiguration
|
||||
var config: DaemonConfiguration
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private let pollInterval: TimeInterval = 8
|
||||
private let timeout: TimeInterval = 4
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ final class FileSyncPipeline: ObservableObject {
|
|||
|
||||
private let buildService: BuildService
|
||||
private let gitService: GitService
|
||||
private let gitStore: GitStore
|
||||
private var gitStore: GitStore
|
||||
|
||||
/// Keyed by file path — cancels previous debounce when a new edit arrives.
|
||||
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
||||
|
|
@ -48,6 +48,12 @@ final class FileSyncPipeline: ObservableObject {
|
|||
/// Stores a mapping from the Mac absolute root path to the WC repo name.
|
||||
/// projectRoot: "/Users/dallas/Dev/NavidromePlayer/NavidromePlayer"
|
||||
/// repoName: "NavidromePlayer"
|
||||
/// Rewires to use the real environment GitStore (not the shadow created in ContentView.init).
|
||||
/// Called from ContentView.initialLoad() after @EnvironmentObjects are available.
|
||||
func rewireGitStore(_ gitStore: GitStore) {
|
||||
self.gitStore = gitStore
|
||||
}
|
||||
|
||||
func registerProject(projectRoot: String, repoName: String) {
|
||||
UserDefaults.standard.set(repoName, forKey: wcKey(projectRoot))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ final class LSPClient: ObservableObject {
|
|||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var messageId = 0
|
||||
private var pendingRequests: [Int: CheckedContinuation<LSPResponse, Error>] = [:]
|
||||
private let config: DaemonConfiguration
|
||||
var config: DaemonConfiguration
|
||||
|
||||
init(config: DaemonConfiguration) { self.config = config }
|
||||
|
||||
|
|
@ -206,8 +206,11 @@ final class LSPClient: ObservableObject {
|
|||
webSocketTask?.send(.string(text)) { error in
|
||||
if let error {
|
||||
Task { @MainActor in
|
||||
self.pendingRequests.removeValue(forKey: id)
|
||||
cont.resume(throwing: error)
|
||||
// Only resume if this request is still pending — handleMessage
|
||||
// may have already received a response and resumed it.
|
||||
if self.pendingRequests.removeValue(forKey: id) != nil {
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -221,22 +224,30 @@ final class LSPClient: ObservableObject {
|
|||
}
|
||||
|
||||
private func receiveLoop() async {
|
||||
guard let task = webSocketTask, task.state == .running else {
|
||||
guard let task = webSocketTask else {
|
||||
isConnected = false; return
|
||||
}
|
||||
do {
|
||||
let message = try await task.receive()
|
||||
switch message {
|
||||
case .string(let text): handleMessage(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) { handleMessage(text) }
|
||||
@unknown default: break
|
||||
while task.state == .running {
|
||||
do {
|
||||
let message = try await task.receive()
|
||||
switch message {
|
||||
case .string(let text): handleMessage(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) { handleMessage(text) }
|
||||
@unknown default: break
|
||||
}
|
||||
} catch {
|
||||
isConnected = false
|
||||
config.lspConnected = false
|
||||
// Drain pending continuations so callers don't hang forever
|
||||
for (_, cont) in pendingRequests {
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
return
|
||||
}
|
||||
await receiveLoop()
|
||||
} catch {
|
||||
isConnected = false
|
||||
config.lspConnected = false
|
||||
}
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
private func handleMessage(_ text: String) {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ struct OnboardingView: View {
|
|||
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 28) {
|
||||
Image(systemName: "laptopcomputer.and.ipad").font(.system(size:72,weight:.thin)).foregroundStyle(.accentColor).padding(.top,40)
|
||||
Image(systemName: "laptopcomputer.and.ipad").font(.system(size:72,weight:.thin)).foregroundStyle(Color.accentColor).padding(.top,40)
|
||||
VStack(spacing:10) {
|
||||
Text("Welcome to PadXcode").font(.largeTitle.bold()).multilineTextAlignment(.center)
|
||||
Text("A native Swift IDE on your iPad, backed by your Mac.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24)
|
||||
|
|
|
|||
|
|
@ -3,23 +3,26 @@ import Foundation
|
|||
|
||||
@main
|
||||
struct PadXcodeApp: App {
|
||||
@StateObject private var daemonConfig = DaemonConfiguration()
|
||||
@StateObject private var projectStore = ProjectStore()
|
||||
@StateObject private var editorState = EditorState()
|
||||
@StateObject private var gitStore = GitStore()
|
||||
@StateObject private var lspClient: LSPClient
|
||||
|
||||
private let gitService: GitService
|
||||
@StateObject private var daemonConfig: DaemonConfiguration
|
||||
@StateObject private var projectStore: ProjectStore
|
||||
@StateObject private var editorState: EditorState
|
||||
@StateObject private var gitStore: GitStore
|
||||
@StateObject private var lspClient: LSPClient
|
||||
|
||||
private let gitService: GitService
|
||||
private let gitCallbackHandler: GitCallbackHandler
|
||||
|
||||
init() {
|
||||
let config = DaemonConfiguration()
|
||||
let apiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
|
||||
let store = GitStore()
|
||||
_daemonConfig = StateObject(wrappedValue: config)
|
||||
_lspClient = StateObject(wrappedValue: LSPClient(config: config))
|
||||
_projectStore = StateObject(wrappedValue: ProjectStore())
|
||||
_editorState = StateObject(wrappedValue: EditorState())
|
||||
_gitStore = StateObject(wrappedValue: store)
|
||||
gitService = GitService(apiKey: apiKey)
|
||||
_lspClient = StateObject(wrappedValue: LSPClient(config: config))
|
||||
|
||||
gitService = GitService()
|
||||
gitCallbackHandler = GitCallbackHandler(store: store)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import UIKit
|
|||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var daemonConfig: DaemonConfiguration
|
||||
@ObservedObject var editorState: EditorState
|
||||
@ObservedObject var projectStore: ProjectStore
|
||||
@ObservedObject var editorState: EditorState
|
||||
@ObservedObject var projectStore: ProjectStore
|
||||
|
||||
@State private var pendingHost = ""
|
||||
@State private var pendingPort = ""
|
||||
|
|
@ -12,129 +12,243 @@ struct SettingsView: View {
|
|||
@State private var connStatus: ConnStatus = .untested
|
||||
@State private var showAddProject = false
|
||||
|
||||
enum ConnStatus { case untested, testing, success, failure(String)
|
||||
var label: String { switch self { case .untested: return "Not tested"; case .testing: return "Testing…"; case .success: return "Connected ✓"; case .failure(let m): return "Failed: \(m)" } }
|
||||
var color: Color { switch self { case .success: return .green; case .failure: return .red; case .testing: return .secondary; default: return .secondary } }
|
||||
static func ==(a: ConnStatus, b: ConnStatus) -> Bool {
|
||||
switch (a,b) { case (.success,.success),(.untested,.untested),(.testing,.testing): return true; case (.failure(let x),.failure(let y)): return x==y; default: return false }
|
||||
// Equatable conformance added so != operator works on line 39 and elsewhere.
|
||||
enum ConnStatus: Equatable {
|
||||
case untested, testing, success, failure(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .untested: return "Not tested"
|
||||
case .testing: return "Testing..."
|
||||
case .success: return "Connected ✓"
|
||||
case .failure(let m): return "Failed: \(m)"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .success: return .green
|
||||
case .failure: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
// Custom == required because .failure has an associated value.
|
||||
static func == (a: ConnStatus, b: ConnStatus) -> Bool {
|
||||
switch (a, b) {
|
||||
case (.success, .success), (.untested, .untested), (.testing, .testing): return true
|
||||
case (.failure(let x), .failure(let y)): return x == y
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// ── Daemon ────────────────────────────────────────────────
|
||||
|
||||
// ── Daemon ──────────────────────────────────────────────────
|
||||
Section {
|
||||
HStack {
|
||||
Label("Tailscale IP", systemImage:"network"); Spacer()
|
||||
TextField("100.x.x.x", text:$pendingHost).multilineTextAlignment(.trailing).keyboardType(.numbersAndPunctuation).autocorrectionDisabled().autocapitalization(.none).frame(width:160).onChange(of:pendingHost){_,_ in connStatus = .untested}
|
||||
Label("Tailscale IP", systemImage: "network")
|
||||
Spacer()
|
||||
TextField("100.x.x.x", text: $pendingHost)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
.autocorrectionDisabled()
|
||||
.autocapitalization(.none)
|
||||
.frame(width: 160)
|
||||
.onChange(of: pendingHost) { _, _ in connStatus = .untested }
|
||||
}
|
||||
HStack {
|
||||
Label("Port", systemImage:"antenna.radiowaves.left.and.right"); Spacer()
|
||||
TextField("8080", text:$pendingPort).multilineTextAlignment(.trailing).keyboardType(.numberPad).frame(width:80)
|
||||
Label("Port", systemImage: "antenna.radiowaves.left.and.right")
|
||||
Spacer()
|
||||
TextField("8080", text: $pendingPort)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 80)
|
||||
}
|
||||
HStack { Text("Status").foregroundStyle(.secondary); Spacer(); Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline) }
|
||||
Button("Test Connection") { Task { await testConnection() } }.disabled(pendingHost.isEmpty)
|
||||
Button("Save & Apply") { applyConnection() }.buttonStyle(.borderedProminent)
|
||||
HStack {
|
||||
Text("Status").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline)
|
||||
}
|
||||
Button("Test Connection") { Task { await testConnection() } }
|
||||
.disabled(pendingHost.isEmpty)
|
||||
Button("Save & Apply") { applyConnection() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
// != now works because ConnStatus conforms to Equatable.
|
||||
.disabled(pendingHost.isEmpty || connStatus != .success)
|
||||
} header: { Text("Mac Daemon") }
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────
|
||||
// ── Projects ────────────────────────────────────────────────
|
||||
Section {
|
||||
ForEach(projectStore.projects) { p in
|
||||
VStack(alignment:.leading, spacing:2) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(p.name).font(.body)
|
||||
Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)").font(.caption2).foregroundStyle(.tertiary)
|
||||
}.padding(.vertical,2)
|
||||
}.onDelete { projectStore.remove(atOffsets:$0) }
|
||||
Button { showAddProject = true } label: { Label("Add Project", systemImage:"plus.circle") }
|
||||
} header: { Text("Projects") } footer: { Text("Mac path should be the directory containing project.yml or .xcodeproj") }
|
||||
Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.onDelete { projectStore.remove(atOffsets: $0) }
|
||||
Button { showAddProject = true } label: {
|
||||
Label("Add Project", systemImage: "plus.circle")
|
||||
}
|
||||
} header: {
|
||||
Text("Projects")
|
||||
} footer: {
|
||||
Text("Mac path should be the directory containing project.yml or .xcodeproj")
|
||||
}
|
||||
|
||||
// ── Editor ────────────────────────────────────────────────
|
||||
// ── Editor ──────────────────────────────────────────────────
|
||||
Section("Editor Appearance") {
|
||||
HStack {
|
||||
Label("Font Size", systemImage:"textformat.size"); Spacer()
|
||||
Stepper("\(Int(editorState.fontSize))pt", value:$editorState.fontSize, in:8...32, step:1).frame(width:160)
|
||||
Label("Font Size", systemImage: "textformat.size")
|
||||
Spacer()
|
||||
// fontSizeStored is the @AppStorage Double backing fontSize.
|
||||
// Binding to the computed fontSize property is not possible;
|
||||
// bind to the stored Double and display with Int cast.
|
||||
Stepper(
|
||||
"\(Int(editorState.fontSizeStored))pt",
|
||||
value: $editorState.fontSizeStored,
|
||||
in: 8.0...32.0,
|
||||
step: 1.0
|
||||
)
|
||||
.frame(width: 160)
|
||||
}
|
||||
Picker(selection:$editorState.selectedTheme) {
|
||||
ForEach(EditorThemeOption.allCases) { o in Text(o.displayName).tag(o) }
|
||||
} label: { Label("Theme", systemImage:"paintbrush") }
|
||||
Toggle(isOn:$editorState.showLineNumbers) { Label("Line Numbers", systemImage:"list.number") }
|
||||
Toggle(isOn:$editorState.lineWrapping) { Label("Line Wrapping", systemImage:"text.word.spacing") }
|
||||
// selectedTheme is a computed property on EditorState —
|
||||
// cannot use $ directly; use an explicit Binding.
|
||||
Picker(selection: Binding(
|
||||
get: { editorState.selectedTheme },
|
||||
set: { editorState.selectedTheme = $0 }
|
||||
)) {
|
||||
ForEach(EditorThemeOption.allCases) { o in
|
||||
Text(o.displayName).tag(o)
|
||||
}
|
||||
} label: {
|
||||
Label("Theme", systemImage: "paintbrush")
|
||||
}
|
||||
Toggle(isOn: $editorState.showLineNumbers) { Label("Line Numbers", systemImage: "list.number") }
|
||||
Toggle(isOn: $editorState.lineWrapping) { Label("Line Wrapping", systemImage: "text.word.spacing") }
|
||||
HStack {
|
||||
Label("Indent Width", systemImage:"arrow.right.to.line"); Spacer()
|
||||
Picker("", selection:$editorState.indentWidth) {
|
||||
Text("2 spaces").tag(2); Text("4 spaces").tag(4); Text("Tab").tag(0)
|
||||
}.labelsHidden().pickerStyle(.menu)
|
||||
Label("Indent Width", systemImage: "arrow.right.to.line")
|
||||
Spacer()
|
||||
Picker("", selection: $editorState.indentWidth) {
|
||||
Text("2 spaces").tag(2)
|
||||
Text("4 spaces").tag(4)
|
||||
Text("Tab").tag(0)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ─────────────────────────────────────────────────
|
||||
// ── Build ────────────────────────────────────────────────────
|
||||
Section("Build") {
|
||||
HStack {
|
||||
Label("Team ID", systemImage:"person.badge.key"); Spacer()
|
||||
TextField("XXXXXXXXXX", text:$daemonConfig.developmentTeam).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:120)
|
||||
Label("Team ID", systemImage: "person.badge.key")
|
||||
Spacer()
|
||||
TextField("XXXXXXXXXX", text: $daemonConfig.developmentTeam)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.autocorrectionDisabled()
|
||||
.autocapitalization(.none)
|
||||
.frame(width: 120)
|
||||
}
|
||||
Toggle(isOn: $daemonConfig.allowProvisioningUpdates) {
|
||||
Label("Allow Provisioning Updates", systemImage: "checkmark.seal")
|
||||
}
|
||||
Toggle(isOn: $daemonConfig.buildInRelease) {
|
||||
Label("Build in Release", systemImage: "bolt")
|
||||
}
|
||||
Toggle(isOn:$daemonConfig.allowProvisioningUpdates) { Label("Allow Provisioning Updates", systemImage:"checkmark.seal") }
|
||||
Toggle(isOn:$daemonConfig.buildInRelease) { Label("Build in Release", systemImage:"bolt") }
|
||||
}
|
||||
|
||||
// ── Working Copy ──────────────────────────────────────────
|
||||
// ── Working Copy ─────────────────────────────────────────────
|
||||
Section {
|
||||
HStack {
|
||||
Label("API Key", systemImage:"key.fill"); Spacer()
|
||||
SecureField("Paste from Working Copy", text:$wcApiKey).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:200)
|
||||
.onChange(of:wcApiKey){_,k in UserDefaults.standard.set(k, forKey:"workingCopyAPIKey")}
|
||||
Label("API Key", systemImage: "key.fill")
|
||||
Spacer()
|
||||
SecureField("Paste from Working Copy", text: $wcApiKey)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.autocorrectionDisabled()
|
||||
.autocapitalization(.none)
|
||||
.frame(width: 200)
|
||||
.onChange(of: wcApiKey) { _, k in
|
||||
UserDefaults.standard.set(k, forKey: "workingCopyAPIKey")
|
||||
}
|
||||
}
|
||||
let installed = UIApplication.shared.canOpenURL(URL(string:"working-copy://")!)
|
||||
Label(installed ? "Working Copy detected ✓" : "Working Copy not installed",
|
||||
systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle")
|
||||
.foregroundStyle(installed ? .green : .orange).font(.caption)
|
||||
let installed = UIApplication.shared.canOpenURL(URL(string: "working-copy://")!)
|
||||
Label(
|
||||
installed ? "Working Copy detected" : "Working Copy not installed",
|
||||
systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle"
|
||||
)
|
||||
.foregroundStyle(installed ? .green : .orange)
|
||||
.font(.caption)
|
||||
Text("For local git over Tailscale, clone in Working Copy using:\nssh://user@100.x.x.x/path/to/repo.git")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} header: { Text("Working Copy (Git)") }
|
||||
} header: {
|
||||
Text("Working Copy (Git)")
|
||||
}
|
||||
|
||||
// ── LSP ───────────────────────────────────────────────────
|
||||
// ── LSP ──────────────────────────────────────────────────────
|
||||
Section {
|
||||
Toggle(isOn:$daemonConfig.lspEnabled) { Label("sourcekit-lsp Proxy", systemImage:"wand.and.stars") }
|
||||
Toggle(isOn: $daemonConfig.lspEnabled) {
|
||||
Label("sourcekit-lsp Proxy", systemImage: "wand.and.stars")
|
||||
}
|
||||
if daemonConfig.lspEnabled {
|
||||
HStack {
|
||||
Label("Status", systemImage:"circle.fill").foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red)
|
||||
Label("Status", systemImage: "circle.fill")
|
||||
.foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red)
|
||||
Spacer()
|
||||
Text(daemonConfig.lspConnected ? "Running" : "Disconnected").foregroundStyle(.secondary)
|
||||
Text(daemonConfig.lspConnected ? "Running" : "Disconnected")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: { Text("Language Server (LSP)") }
|
||||
|
||||
// ── About ─────────────────────────────────────────────────
|
||||
// ── About ─────────────────────────────────────────────────────
|
||||
Section("About") {
|
||||
LabeledContent("Version", value:"1.0.0-alpha")
|
||||
LabeledContent("Editor Engine", value:"Runestone 0.5.1 / Tree-sitter")
|
||||
LabeledContent("Protocol", value:"REST + WebSocket")
|
||||
LabeledContent("Version", value: "1.0.0-alpha")
|
||||
LabeledContent("Editor Engine", value: "Runestone 0.5.1 / Tree-sitter")
|
||||
LabeledContent("Protocol", value: "REST + WebSocket")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings").navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { pendingHost = daemonConfig.host; pendingPort = String(daemonConfig.port)
|
||||
wcApiKey = UserDefaults.standard.string(forKey:"workingCopyAPIKey") ?? "" }
|
||||
.sheet(isPresented:$showAddProject) { AddProjectView(projectStore:projectStore) }
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
pendingHost = daemonConfig.host
|
||||
pendingPort = String(daemonConfig.port)
|
||||
wcApiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
|
||||
}
|
||||
.sheet(isPresented: $showAddProject) {
|
||||
AddProjectView(projectStore: projectStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func testConnection() async {
|
||||
connStatus = .testing
|
||||
let host = pendingHost.trimmingCharacters(in:.whitespaces)
|
||||
let host = pendingHost.trimmingCharacters(in: .whitespaces)
|
||||
let port = Int(pendingPort) ?? 8080
|
||||
guard let url = URL(string:"http://\(host):\(port)/health") else { connStatus = .failure("Invalid URL"); return }
|
||||
var req = URLRequest(url:url); req.timeoutInterval = 4
|
||||
guard let url = URL(string: "http://\(host):\(port)/health") else {
|
||||
connStatus = .failure("Invalid URL"); return
|
||||
}
|
||||
var req = URLRequest(url: url); req.timeoutInterval = 4
|
||||
do {
|
||||
let (_,resp) = try await URLSession.shared.data(for:req)
|
||||
connStatus = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response")
|
||||
} catch { connStatus = .failure(error.localizedDescription) }
|
||||
let (_, resp) = try await URLSession.shared.data(for: req)
|
||||
connStatus = (resp as? HTTPURLResponse)?.statusCode == 200
|
||||
? .success
|
||||
: .failure("Unexpected status code")
|
||||
} catch {
|
||||
connStatus = .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyConnection() {
|
||||
daemonConfig.host = pendingHost.trimmingCharacters(in:.whitespaces)
|
||||
daemonConfig.host = pendingHost.trimmingCharacters(in: .whitespaces)
|
||||
daemonConfig.port = Int(pendingPort) ?? 8080
|
||||
}
|
||||
}
|
||||
|
|
@ -144,33 +258,57 @@ struct SettingsView: View {
|
|||
struct AddProjectView: View {
|
||||
@ObservedObject var projectStore: ProjectStore
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var name = ""; @State private var path = ""
|
||||
@State private var scheme = ""; @State private var wcRepo = ""
|
||||
|
||||
@State private var name = ""
|
||||
@State private var path = ""
|
||||
@State private var scheme = ""
|
||||
@State private var wcRepo = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Project Details") {
|
||||
TextField("Name (e.g. NavidromePlayer)", text:$name)
|
||||
TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text:$path).autocorrectionDisabled().autocapitalization(.none).keyboardType(.URL)
|
||||
TextField("Xcode Scheme", text:$scheme).autocorrectionDisabled().autocapitalization(.none)
|
||||
TextField("Name (e.g. NavidromePlayer)", text: $name)
|
||||
TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text: $path)
|
||||
.autocorrectionDisabled()
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
TextField("Xcode Scheme", text: $scheme)
|
||||
.autocorrectionDisabled()
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
// Section with both header string AND footer requires the
|
||||
// explicit header:/footer: trailing closure form.
|
||||
Section {
|
||||
TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text: $wcRepo)
|
||||
.autocorrectionDisabled()
|
||||
.autocapitalization(.none)
|
||||
} header: {
|
||||
Text("Working Copy")
|
||||
} footer: {
|
||||
Text("Must match the display name in Working Copy's repo list.")
|
||||
}
|
||||
Section("Working Copy") {
|
||||
TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text:$wcRepo).autocorrectionDisabled().autocapitalization(.none)
|
||||
} footer: { Text("Must match the display name in Working Copy's repo list.") }
|
||||
}
|
||||
.navigationTitle("Add Project").navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Add Project")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement:.cancellationAction) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement:.confirmationAction) {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Add") {
|
||||
projectStore.add(SavedProject(name: name.isEmpty ? scheme : name,
|
||||
rootPath: path, scheme: scheme,
|
||||
workingCopyRepoName: wcRepo))
|
||||
projectStore.add(SavedProject(
|
||||
name: name.isEmpty ? scheme : name,
|
||||
rootPath: path,
|
||||
scheme: scheme,
|
||||
workingCopyRepoName: wcRepo
|
||||
))
|
||||
dismiss()
|
||||
}.disabled(path.isEmpty || scheme.isEmpty)
|
||||
}
|
||||
.disabled(path.isEmpty || scheme.isEmpty)
|
||||
}
|
||||
}
|
||||
}.presentationDetents([.medium])
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - File System
|
||||
|
|
@ -68,11 +69,17 @@ struct FileContentResponse: Codable {
|
|||
|
||||
// MARK: - Console
|
||||
|
||||
struct ConsoleLine: Identifiable {
|
||||
struct ConsoleLine: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
let type: LineType
|
||||
|
||||
// Equatable based on content, not identity — prevents spurious re-renders
|
||||
// when identical lines are produced by fromBuildLogMessage.
|
||||
static func == (lhs: ConsoleLine, rhs: ConsoleLine) -> Bool {
|
||||
lhs.text == rhs.text && lhs.type == rhs.type
|
||||
}
|
||||
|
||||
enum LineType { case standard, error, warning, success, info }
|
||||
|
||||
var color: Color {
|
||||
|
|
@ -159,6 +166,9 @@ extension Notification.Name {
|
|||
static let navigateToRange = Notification.Name("editor.navigate.range")
|
||||
static let triggerCompletion = Notification.Name("editor.trigger.completion")
|
||||
static let openSettings = Notification.Name("padxcode.open.settings")
|
||||
static let editorFindNext = Notification.Name("editor.find.next")
|
||||
static let editorFindPrev = Notification.Name("editor.find.prev")
|
||||
static let editorFontSizeChange = Notification.Name("editor.font.size.change")
|
||||
}
|
||||
|
||||
// MARK: - String helpers
|
||||
|
|
@ -172,3 +182,20 @@ extension String {
|
|||
return self.replacingCharacters(in: range, with: replacement)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor hex convenience (used by themes and editor views)
|
||||
|
||||
extension UIColor {
|
||||
convenience init(hex: String) {
|
||||
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
h = h.hasPrefix("#") ? String(h.dropFirst()) : h
|
||||
var rgb: UInt64 = 0
|
||||
Scanner(string: h).scanHexInt64(&rgb)
|
||||
self.init(
|
||||
red: CGFloat((rgb >> 16) & 0xFF) / 255.0,
|
||||
green: CGFloat((rgb >> 8) & 0xFF) / 255.0,
|
||||
blue: CGFloat( rgb & 0xFF) / 255.0,
|
||||
alpha: 1.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import Foundation
|
||||
import Runestone
|
||||
import TreeSitterSwiftRunestone
|
||||
import TreeSitterJSONRunestone
|
||||
|
|
|
|||
|
|
@ -1,151 +1,268 @@
|
|||
import UIKit
|
||||
import Runestone
|
||||
|
||||
final class PadXcodeTheme: Theme {
|
||||
var backgroundColor: UIColor { UIColor(hex: "#1E1E1E") }
|
||||
var userInterfaceStyle: UIUserInterfaceStyle { .dark }
|
||||
var font: UIFont { UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) }
|
||||
var textColor: UIColor { UIColor(hex: "#D4D4D4") }
|
||||
var gutterBackgroundColor: UIColor { UIColor(hex: "#252526") }
|
||||
var gutterHairlineColor: UIColor { UIColor(hex: "#3C3C3C") }
|
||||
var lineNumberColor: UIColor { UIColor(hex: "#6E7681") }
|
||||
var lineNumberFont: UIFont { UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) }
|
||||
var selectedLineBackgroundColor: UIColor { UIColor(hex: "#2A2D2E") }
|
||||
var selectedLinesLineNumberColor: UIColor { UIColor(hex: "#C8C8C8") }
|
||||
var selectedLinesGutterBackgroundColor: UIColor { UIColor(hex: "#2A2D2E") }
|
||||
var markedTextBackgroundColor: UIColor { UIColor(hex: "#264F78").withAlphaComponent(0.4) }
|
||||
var markedTextBackgroundCornerRadius: CGFloat { 2 }
|
||||
var invisibleCharactersColor: UIColor { UIColor(hex: "#404040") }
|
||||
var pageGuideHairlineColor: UIColor { UIColor(hex: "#404040") }
|
||||
var pageGuideBackgroundColor: UIColor { UIColor(hex: "#1A1A1A") }
|
||||
// MARK: - Xcode Default Dark Theme
|
||||
|
||||
func textStyle(for highlightName: String) -> TextStyle? {
|
||||
final class PadXcodeTheme: Theme {
|
||||
|
||||
let fontSize: CGFloat
|
||||
init(fontSize: CGFloat = 14) { self.fontSize = fontSize }
|
||||
|
||||
// MARK: Required visual properties
|
||||
|
||||
var font: UIFont {
|
||||
UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||
}
|
||||
|
||||
var textColor: UIColor {
|
||||
UIColor(r: 212, g: 212, b: 212)
|
||||
}
|
||||
|
||||
var backgroundColor: UIColor {
|
||||
UIColor(r: 30, g: 30, b: 30)
|
||||
}
|
||||
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
.dark
|
||||
}
|
||||
|
||||
var gutterBackgroundColor: UIColor {
|
||||
UIColor(r: 37, g: 37, b: 38)
|
||||
}
|
||||
|
||||
var gutterHairlineColor: UIColor {
|
||||
UIColor(r: 60, g: 60, b: 60)
|
||||
}
|
||||
|
||||
var lineNumberColor: UIColor {
|
||||
UIColor(r: 110, g: 118, b: 129)
|
||||
}
|
||||
|
||||
var lineNumberFont: UIFont {
|
||||
UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
}
|
||||
|
||||
var selectedLineBackgroundColor: UIColor {
|
||||
UIColor(r: 42, g: 45, b: 46)
|
||||
}
|
||||
|
||||
var selectedLinesLineNumberColor: UIColor {
|
||||
UIColor(r: 200, g: 200, b: 200)
|
||||
}
|
||||
|
||||
var selectedLinesGutterBackgroundColor: UIColor {
|
||||
UIColor(r: 42, g: 45, b: 46)
|
||||
}
|
||||
|
||||
var markedTextBackgroundColor: UIColor {
|
||||
UIColor(r: 38, g: 79, b: 120, alpha: 0.4)
|
||||
}
|
||||
|
||||
var markedTextBackgroundCornerRadius: CGFloat {
|
||||
2
|
||||
}
|
||||
|
||||
var invisibleCharactersColor: UIColor {
|
||||
UIColor(r: 64, g: 64, b: 64)
|
||||
}
|
||||
|
||||
var pageGuideHairlineColor: UIColor {
|
||||
UIColor(r: 64, g: 64, b: 64)
|
||||
}
|
||||
|
||||
var pageGuideBackgroundColor: UIColor {
|
||||
UIColor(r: 26, g: 26, b: 26)
|
||||
}
|
||||
|
||||
// MARK: Syntax token colours (required by Theme protocol)
|
||||
|
||||
func textColor(for highlightName: String) -> UIColor? {
|
||||
switch highlightName {
|
||||
case "keyword", "keyword.control", "keyword.operator", "keyword.other",
|
||||
"keyword.declaration", "storage.type":
|
||||
return TextStyle(color: UIColor(hex: "#FF7AB2"), bold: true)
|
||||
case "storage.modifier":
|
||||
return TextStyle(color: UIColor(hex: "#FF7AB2"))
|
||||
case "type", "type.builtin", "entity.name.type", "entity.name.type.class",
|
||||
"entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol":
|
||||
return TextStyle(color: UIColor(hex: "#DABAFF"))
|
||||
case "keyword", "keyword.control", "keyword.operator",
|
||||
"keyword.other", "keyword.declaration", "storage.type",
|
||||
"storage.modifier":
|
||||
return UIColor(r: 255, g: 122, b: 178) // pink
|
||||
|
||||
case "type", "type.builtin", "entity.name.type",
|
||||
"entity.name.type.class", "entity.name.type.struct",
|
||||
"entity.name.type.enum", "entity.name.type.protocol":
|
||||
return UIColor(r: 218, g: 186, b: 255) // lavender
|
||||
|
||||
case "type.parameter":
|
||||
return TextStyle(color: UIColor(hex: "#6BDFFF"))
|
||||
case "function", "entity.name.function", "meta.function-call", "function.method":
|
||||
return TextStyle(color: UIColor(hex: "#67B7FB"))
|
||||
case "function.builtin":
|
||||
return TextStyle(color: UIColor(hex: "#67B7FB"), bold: true)
|
||||
case "variable", "variable.other", "variable.other.member":
|
||||
return TextStyle(color: UIColor(hex: "#D4D4D4"))
|
||||
case "variable.builtin":
|
||||
return TextStyle(color: UIColor(hex: "#FF7AB2"))
|
||||
return UIColor(r: 107, g: 223, b: 255) // light blue
|
||||
|
||||
case "function", "entity.name.function",
|
||||
"meta.function-call", "function.method", "function.builtin":
|
||||
return UIColor(r: 103, g: 183, b: 251) // blue
|
||||
|
||||
case "property", "variable.other.property":
|
||||
return TextStyle(color: UIColor(hex: "#72B9D5"))
|
||||
return UIColor(r: 114, g: 185, b: 213) // teal
|
||||
|
||||
case "variable.builtin":
|
||||
return UIColor(r: 255, g: 122, b: 178) // pink (self, super)
|
||||
|
||||
case "constant", "constant.builtin":
|
||||
return TextStyle(color: UIColor(hex: "#D9C97C"), bold: true)
|
||||
return UIColor(r: 217, g: 201, b: 124) // gold bold
|
||||
|
||||
case "constant.numeric", "number":
|
||||
return TextStyle(color: UIColor(hex: "#D9C97C"))
|
||||
return UIColor(r: 217, g: 201, b: 124) // gold
|
||||
|
||||
case "string", "string.special":
|
||||
return TextStyle(color: UIColor(hex: "#FF8170"))
|
||||
return UIColor(r: 255, g: 129, b: 112) // salmon
|
||||
|
||||
case "string.escape":
|
||||
return TextStyle(color: UIColor(hex: "#FF8170"), bold: true)
|
||||
case "comment", "comment.line", "comment.block":
|
||||
return TextStyle(color: UIColor(hex: "#7F8C98"), italic: true)
|
||||
case "comment.block.documentation":
|
||||
return TextStyle(color: UIColor(hex: "#6A9955"), italic: true)
|
||||
case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter":
|
||||
return TextStyle(color: UIColor(hex: "#D4D4D4"))
|
||||
return UIColor(r: 255, g: 129, b: 112) // salmon
|
||||
|
||||
case "comment", "comment.line", "comment.block",
|
||||
"comment.block.documentation":
|
||||
return UIColor(r: 127, g: 140, b: 152) // grey
|
||||
|
||||
case "attribute":
|
||||
return TextStyle(color: UIColor(hex: "#BF86C8"))
|
||||
return UIColor(r: 191, g: 134, b: 200) // purple
|
||||
|
||||
case "operator", "punctuation.operator",
|
||||
"punctuation.bracket", "punctuation.delimiter":
|
||||
return UIColor(r: 212, g: 212, b: 212) // default text
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Xcode Default Light Theme
|
||||
|
||||
final class PadXcodeLightTheme: Theme {
|
||||
var backgroundColor: UIColor { UIColor(hex: "#FFFFFF") }
|
||||
var userInterfaceStyle: UIUserInterfaceStyle { .light }
|
||||
var font: UIFont { UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) }
|
||||
var textColor: UIColor { UIColor(hex: "#000000") }
|
||||
var gutterBackgroundColor: UIColor { UIColor(hex: "#F2F2F2") }
|
||||
var gutterHairlineColor: UIColor { UIColor(hex: "#D8D8D8") }
|
||||
var lineNumberColor: UIColor { UIColor(hex: "#A0A0A0") }
|
||||
var lineNumberFont: UIFont { UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) }
|
||||
var selectedLineBackgroundColor: UIColor { UIColor(hex: "#ECF5FF") }
|
||||
var selectedLinesLineNumberColor: UIColor { UIColor(hex: "#3A3A3A") }
|
||||
var selectedLinesGutterBackgroundColor: UIColor { UIColor(hex: "#E8F0FA") }
|
||||
var markedTextBackgroundColor: UIColor { UIColor(hex: "#B5D0F7").withAlphaComponent(0.4) }
|
||||
var markedTextBackgroundCornerRadius: CGFloat { 2 }
|
||||
var invisibleCharactersColor: UIColor { UIColor(hex: "#CCCCCC") }
|
||||
var pageGuideHairlineColor: UIColor { UIColor(hex: "#DDDDDD") }
|
||||
var pageGuideBackgroundColor: UIColor { UIColor(hex: "#F9F9F9") }
|
||||
|
||||
func textStyle(for highlightName: String) -> TextStyle? {
|
||||
let fontSize: CGFloat
|
||||
init(fontSize: CGFloat = 14) { self.fontSize = fontSize }
|
||||
|
||||
// MARK: Required visual properties
|
||||
|
||||
var font: UIFont {
|
||||
UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||
}
|
||||
|
||||
var textColor: UIColor {
|
||||
UIColor(r: 0, g: 0, b: 0)
|
||||
}
|
||||
|
||||
var backgroundColor: UIColor {
|
||||
UIColor(r: 255, g: 255, b: 255)
|
||||
}
|
||||
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
.light
|
||||
}
|
||||
|
||||
var gutterBackgroundColor: UIColor {
|
||||
UIColor(r: 242, g: 242, b: 242)
|
||||
}
|
||||
|
||||
var gutterHairlineColor: UIColor {
|
||||
UIColor(r: 216, g: 216, b: 216)
|
||||
}
|
||||
|
||||
var lineNumberColor: UIColor {
|
||||
UIColor(r: 160, g: 160, b: 160)
|
||||
}
|
||||
|
||||
var lineNumberFont: UIFont {
|
||||
UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
}
|
||||
|
||||
var selectedLineBackgroundColor: UIColor {
|
||||
UIColor(r: 236, g: 245, b: 255)
|
||||
}
|
||||
|
||||
var selectedLinesLineNumberColor: UIColor {
|
||||
UIColor(r: 58, g: 58, b: 58)
|
||||
}
|
||||
|
||||
var selectedLinesGutterBackgroundColor: UIColor {
|
||||
UIColor(r: 232, g: 240, b: 250)
|
||||
}
|
||||
|
||||
var markedTextBackgroundColor: UIColor {
|
||||
UIColor(r: 181, g: 208, b: 247, alpha: 0.4)
|
||||
}
|
||||
|
||||
var markedTextBackgroundCornerRadius: CGFloat {
|
||||
2
|
||||
}
|
||||
|
||||
var invisibleCharactersColor: UIColor {
|
||||
UIColor(r: 204, g: 204, b: 204)
|
||||
}
|
||||
|
||||
var pageGuideHairlineColor: UIColor {
|
||||
UIColor(r: 221, g: 221, b: 221)
|
||||
}
|
||||
|
||||
var pageGuideBackgroundColor: UIColor {
|
||||
UIColor(r: 249, g: 249, b: 249)
|
||||
}
|
||||
|
||||
// MARK: Syntax token colours (required by Theme protocol)
|
||||
|
||||
func textColor(for highlightName: String) -> UIColor? {
|
||||
switch highlightName {
|
||||
case "keyword", "keyword.control", "keyword.operator", "keyword.other",
|
||||
"keyword.declaration", "storage.type":
|
||||
return TextStyle(color: UIColor(hex: "#AD3DA4"), bold: true)
|
||||
case "storage.modifier":
|
||||
return TextStyle(color: UIColor(hex: "#AD3DA4"))
|
||||
case "type", "type.builtin", "entity.name.type", "entity.name.type.class",
|
||||
"entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol":
|
||||
return TextStyle(color: UIColor(hex: "#703DAA"))
|
||||
case "keyword", "keyword.control", "keyword.operator",
|
||||
"keyword.other", "keyword.declaration", "storage.type",
|
||||
"storage.modifier":
|
||||
return UIColor(r: 173, g: 61, b: 164) // purple
|
||||
|
||||
case "type", "type.builtin", "entity.name.type",
|
||||
"entity.name.type.class", "entity.name.type.struct",
|
||||
"entity.name.type.enum", "entity.name.type.protocol":
|
||||
return UIColor(r: 112, g: 61, b: 170) // dark purple
|
||||
|
||||
case "type.parameter":
|
||||
return TextStyle(color: UIColor(hex: "#047CB0"))
|
||||
case "function", "entity.name.function", "meta.function-call", "function.method":
|
||||
return TextStyle(color: UIColor(hex: "#3900A0"))
|
||||
case "function.builtin":
|
||||
return TextStyle(color: UIColor(hex: "#3900A0"), bold: true)
|
||||
case "variable", "variable.other", "variable.other.member":
|
||||
return TextStyle(color: UIColor(hex: "#000000"))
|
||||
case "variable.builtin":
|
||||
return TextStyle(color: UIColor(hex: "#AD3DA4"))
|
||||
return UIColor(r: 4, g: 124, b: 176) // blue
|
||||
|
||||
case "function", "entity.name.function",
|
||||
"meta.function-call", "function.method", "function.builtin":
|
||||
return UIColor(r: 57, g: 0, b: 160) // indigo
|
||||
|
||||
case "property", "variable.other.property":
|
||||
return TextStyle(color: UIColor(hex: "#047CB0"))
|
||||
return UIColor(r: 4, g: 124, b: 176) // blue
|
||||
|
||||
case "variable.builtin":
|
||||
return UIColor(r: 173, g: 61, b: 164) // purple (self, super)
|
||||
|
||||
case "constant", "constant.builtin":
|
||||
return TextStyle(color: UIColor(hex: "#272AD8"), bold: true)
|
||||
return UIColor(r: 39, g: 42, b: 216) // blue bold
|
||||
|
||||
case "constant.numeric", "number":
|
||||
return TextStyle(color: UIColor(hex: "#272AD8"))
|
||||
return UIColor(r: 39, g: 42, b: 216) // blue
|
||||
|
||||
case "string", "string.special":
|
||||
return TextStyle(color: UIColor(hex: "#C41A16"))
|
||||
return UIColor(r: 196, g: 26, b: 22) // red
|
||||
|
||||
case "string.escape":
|
||||
return TextStyle(color: UIColor(hex: "#C41A16"), bold: true)
|
||||
case "comment", "comment.line", "comment.block":
|
||||
return TextStyle(color: UIColor(hex: "#5D6C79"), italic: true)
|
||||
case "comment.block.documentation":
|
||||
return TextStyle(color: UIColor(hex: "#265B31"), italic: true)
|
||||
case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter":
|
||||
return TextStyle(color: UIColor(hex: "#000000"))
|
||||
return UIColor(r: 196, g: 26, b: 22) // red
|
||||
|
||||
case "comment", "comment.line", "comment.block",
|
||||
"comment.block.documentation":
|
||||
return UIColor(r: 93, g: 108, b: 121) // grey
|
||||
|
||||
case "attribute":
|
||||
return TextStyle(color: UIColor(hex: "#6C36A9"))
|
||||
return UIColor(r: 108, g: 54, b: 169) // purple
|
||||
|
||||
case "operator", "punctuation.operator",
|
||||
"punctuation.bracket", "punctuation.delimiter":
|
||||
return nil // inherits default textColor
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TextStyle helpers
|
||||
// MARK: - UIColor RGB convenience (private to this file)
|
||||
|
||||
extension TextStyle {
|
||||
init(color: UIColor, bold: Bool = false, italic: Bool = false) {
|
||||
self.init()
|
||||
self.color = color
|
||||
if bold { self.fontTraits.insert(.traitBold) }
|
||||
if italic { self.fontTraits.insert(.traitItalic) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor hex
|
||||
|
||||
extension UIColor {
|
||||
convenience init(hex: String) {
|
||||
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
h = h.hasPrefix("#") ? String(h.dropFirst()) : h
|
||||
var rgb: UInt64 = 0
|
||||
Scanner(string: h).scanHexInt64(&rgb)
|
||||
self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((rgb >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat( rgb & 0xFF) / 255, alpha: 1)
|
||||
private extension UIColor {
|
||||
convenience init(r: CGFloat, g: CGFloat, b: CGFloat, alpha: CGFloat = 1.0) {
|
||||
self.init(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: alpha)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ struct Toast: Identifiable {
|
|||
var color: Color {
|
||||
switch self {
|
||||
case .success: return .green; case .error: return .red
|
||||
case .warning: return .orange; case .info: return .accentColor; case .git: return .purple
|
||||
case .warning: return .orange; case .info: return Color.accentColor; case .git: return .purple
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ struct WorkingCopyErrorBanner: ViewModifier {
|
|||
if let err = error {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||||
Text(err.localizedDescription ?? "").font(.subheadline).lineLimit(2)
|
||||
Text(err.localizedDescription).font(.subheadline).lineLimit(2)
|
||||
Spacer()
|
||||
Button(err.actionLabel) { handleAction(for: err) }.buttonStyle(.bordered).controlSize(.small)
|
||||
Button { withAnimation { self.error = nil } }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ settings:
|
|||
SUPPORTS_MAC_DESIGNED_FOR_IPAD: NO
|
||||
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||
DEBUG_INFORMATION_FORMAT: dwarf
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
configs:
|
||||
Release:
|
||||
SWIFT_COMPILATION_MODE: incremental
|
||||
|
||||
packages:
|
||||
Runestone:
|
||||
|
|
@ -71,7 +75,9 @@ targets:
|
|||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: Info.plist
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
CODE_SIGN_ENTITLEMENTS: PadXcode.entitlements
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
|
||||
LD_RUNPATH_SEARCH_PATHS:
|
||||
- "$(inherited)"
|
||||
- "@executable_path/Frameworks"
|
||||
|
|
|
|||
Loading…
Reference in a new issue