mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 07:04:35 +00:00
Add a C# course with a side-by-side code runner.
Each lesson explains a topic and runs the edited program next to the text through the local .NET SDK. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6662eb185e
commit
333085ee53
86 changed files with 2482 additions and 5 deletions
|
|
@ -254,6 +254,26 @@ final class AppModel: ObservableObject {
|
|||
refresh()
|
||||
}
|
||||
|
||||
func markCs(_ id: Int) {
|
||||
progress.markCs(id)
|
||||
refresh()
|
||||
}
|
||||
|
||||
func markCs2(_ id: Int) {
|
||||
progress.markCs2(id)
|
||||
refresh()
|
||||
}
|
||||
|
||||
func markCs3(_ id: Int) {
|
||||
progress.markCs3(id)
|
||||
refresh()
|
||||
}
|
||||
|
||||
func markCs4(_ id: Int) {
|
||||
progress.markCs4(id)
|
||||
refresh()
|
||||
}
|
||||
|
||||
func markMat0(_ id: Int) {
|
||||
progress.markMat0(id)
|
||||
refresh()
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ final class Content {
|
|||
let tg2: [J]
|
||||
let tg3: [J]
|
||||
let tg4: [J]
|
||||
let cs: [J]
|
||||
let cs2: [J]
|
||||
let cs3: [J]
|
||||
let cs4: [J]
|
||||
let mat0: [J]
|
||||
let mat: [J]
|
||||
let mat2: [J]
|
||||
|
|
@ -133,6 +137,10 @@ final class Content {
|
|||
tg2 = raw.arr("tg2")
|
||||
tg3 = raw.arr("tg3")
|
||||
tg4 = raw.arr("tg4")
|
||||
cs = raw.arr("cs")
|
||||
cs2 = raw.arr("cs2")
|
||||
cs3 = raw.arr("cs3")
|
||||
cs4 = raw.arr("cs4")
|
||||
mat0 = raw.arr("mat0")
|
||||
mat = raw.arr("mat")
|
||||
mat2 = raw.arr("mat2")
|
||||
|
|
@ -199,6 +207,10 @@ final class Content {
|
|||
func tg2Lesson(_ id: Int) -> J? { tg2.first { $0.int("id") == id } }
|
||||
func tg3Lesson(_ id: Int) -> J? { tg3.first { $0.int("id") == id } }
|
||||
func tg4Lesson(_ id: Int) -> J? { tg4.first { $0.int("id") == id } }
|
||||
func csLesson(_ id: Int) -> J? { cs.first { $0.int("id") == id } }
|
||||
func cs2Lesson(_ id: Int) -> J? { cs2.first { $0.int("id") == id } }
|
||||
func cs3Lesson(_ id: Int) -> J? { cs3.first { $0.int("id") == id } }
|
||||
func cs4Lesson(_ id: Int) -> J? { cs4.first { $0.int("id") == id } }
|
||||
func mat0Lesson(_ id: Int) -> J? { mat0.first { $0.int("id") == id } }
|
||||
func matLesson(_ id: Int) -> J? { mat.first { $0.int("id") == id } }
|
||||
func mat2Lesson(_ id: Int) -> J? { mat2.first { $0.int("id") == id } }
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ final class ProgressStore {
|
|||
func markTg3(_ id: Int) { mark("tg3.\(id)") }
|
||||
func tg4Done(_ id: Int) -> Bool { done("tg4.\(id)") }
|
||||
func markTg4(_ id: Int) { mark("tg4.\(id)") }
|
||||
func csDone(_ id: Int) -> Bool { done("cs.\(id)") }
|
||||
func markCs(_ id: Int) { mark("cs.\(id)") }
|
||||
func cs2Done(_ id: Int) -> Bool { done("cs2.\(id)") }
|
||||
func markCs2(_ id: Int) { mark("cs2.\(id)") }
|
||||
func cs3Done(_ id: Int) -> Bool { done("cs3.\(id)") }
|
||||
func markCs3(_ id: Int) { mark("cs3.\(id)") }
|
||||
func cs4Done(_ id: Int) -> Bool { done("cs4.\(id)") }
|
||||
func markCs4(_ id: Int) { mark("cs4.\(id)") }
|
||||
func mat0Done(_ id: Int) -> Bool { done("mat0.\(id)") }
|
||||
func markMat0(_ id: Int) { mark("mat0.\(id)") }
|
||||
func matDone(_ id: Int) -> Bool { done("mat.\(id)") }
|
||||
|
|
@ -328,6 +336,38 @@ func summarize(_ content: Content, _ p: ProgressStore) -> ProgressSum {
|
|||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
for l in content.cs {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
if p.csDone(l.int("id")) {
|
||||
sum.doneEx += 1
|
||||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
for l in content.cs2 {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
if p.cs2Done(l.int("id")) {
|
||||
sum.doneEx += 1
|
||||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
for l in content.cs3 {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
if p.cs3Done(l.int("id")) {
|
||||
sum.doneEx += 1
|
||||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
for l in content.cs4 {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
if p.cs4Done(l.int("id")) {
|
||||
sum.doneEx += 1
|
||||
sum.doneUnits += 1
|
||||
}
|
||||
}
|
||||
for l in content.mat0 {
|
||||
sum.totalEx += 1
|
||||
sum.openUnits += 1
|
||||
|
|
|
|||
|
|
@ -124,6 +124,15 @@ enum Route: Hashable {
|
|||
case tg4Map
|
||||
case tg4Lesson(Int)
|
||||
case tg4Ex(Int)
|
||||
case csYears
|
||||
case csMap
|
||||
case csLesson(Int)
|
||||
case cs2Map
|
||||
case cs2Lesson(Int)
|
||||
case cs3Map
|
||||
case cs3Lesson(Int)
|
||||
case cs4Map
|
||||
case cs4Lesson(Int)
|
||||
case czechMap
|
||||
case mluvnice
|
||||
case mluvEx(Int)
|
||||
|
|
@ -250,6 +259,15 @@ enum Route: Hashable {
|
|||
case .tg4Map: return "tg4map"
|
||||
case .tg4Lesson(let id): return "tg4unit\(id)"
|
||||
case .tg4Ex(let id): return "tg4ex\(id)"
|
||||
case .csYears: return "csyears"
|
||||
case .csMap: return "csmap"
|
||||
case .csLesson(let id): return "csunit\(id)"
|
||||
case .cs2Map: return "cs2map"
|
||||
case .cs2Lesson(let id): return "cs2unit\(id)"
|
||||
case .cs3Map: return "cs3map"
|
||||
case .cs3Lesson(let id): return "cs3unit\(id)"
|
||||
case .cs4Map: return "cs4map"
|
||||
case .cs4Lesson(let id): return "cs4unit\(id)"
|
||||
case .czechMap: return "czechmap"
|
||||
case .mluvnice: return "mluvnice"
|
||||
case .mluvEx(let n): return "mluve\(n)"
|
||||
|
|
@ -500,6 +518,24 @@ enum Route: Hashable {
|
|||
return [.tgYears, .tg4Map, .tg4Lesson(id)]
|
||||
case .tg4Ex(let id):
|
||||
return [.tgYears, .tg4Map, .tg4Lesson(id), .tg4Ex(id)]
|
||||
case .csYears:
|
||||
return [.csYears]
|
||||
case .csMap:
|
||||
return [.csYears, .csMap]
|
||||
case .csLesson(let id):
|
||||
return [.csYears, .csMap, .csLesson(id)]
|
||||
case .cs2Map:
|
||||
return [.csYears, .cs2Map]
|
||||
case .cs2Lesson(let id):
|
||||
return [.csYears, .cs2Map, .cs2Lesson(id)]
|
||||
case .cs3Map:
|
||||
return [.csYears, .cs3Map]
|
||||
case .cs3Lesson(let id):
|
||||
return [.csYears, .cs3Map, .cs3Lesson(id)]
|
||||
case .cs4Map:
|
||||
return [.csYears, .cs4Map]
|
||||
case .cs4Lesson(let id):
|
||||
return [.csYears, .cs4Map, .cs4Lesson(id)]
|
||||
case .czechMap:
|
||||
return [.czechMap]
|
||||
case .mluvnice:
|
||||
|
|
@ -565,6 +601,11 @@ func routeFromPage(_ page: String) -> Route? {
|
|||
case "tg2map": return .tg2Map
|
||||
case "tg3map": return .tg3Map
|
||||
case "tg4map": return .tg4Map
|
||||
case "csyears": return .csYears
|
||||
case "csmap": return .csMap
|
||||
case "cs2map": return .cs2Map
|
||||
case "cs3map": return .cs3Map
|
||||
case "cs4map": return .cs4Map
|
||||
case "czechmap": return .czechMap
|
||||
case "mluvnice": return .mluvnice
|
||||
case "readinglist": return .readingList
|
||||
|
|
@ -698,6 +739,18 @@ func routeFromPage(_ page: String) -> Route? {
|
|||
if let m = page.wholeMatch(of: /tgex(\d+)/), let n = Int(m.1) {
|
||||
return .tgEx(n)
|
||||
}
|
||||
if let m = page.wholeMatch(of: /cs4unit(\d+)/), let n = Int(m.1) {
|
||||
return .cs4Lesson(n)
|
||||
}
|
||||
if let m = page.wholeMatch(of: /cs3unit(\d+)/), let n = Int(m.1) {
|
||||
return .cs3Lesson(n)
|
||||
}
|
||||
if let m = page.wholeMatch(of: /cs2unit(\d+)/), let n = Int(m.1) {
|
||||
return .cs2Lesson(n)
|
||||
}
|
||||
if let m = page.wholeMatch(of: /csunit(\d+)/), let n = Int(m.1) {
|
||||
return .csLesson(n)
|
||||
}
|
||||
if let m = page.wholeMatch(of: /lit4unit(\d+)/), let n = Int(m.1) {
|
||||
return .lit4Lesson(n)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -680,6 +680,7 @@ func subjectSymbol(_ icon: String) -> String {
|
|||
case "bolt": return "bolt.fill"
|
||||
case "function": return "function"
|
||||
case "ruler": return "ruler.fill"
|
||||
case "code": return "chevron.left.forwardslash.chevron.right"
|
||||
default: return "lock.fill"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -713,6 +713,12 @@ private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
|
|||
+ vm.content.mat2.filter { vm.progress.mat2Done($0.int("id")) }.count
|
||||
+ vm.content.mat3.filter { vm.progress.mat3Done($0.int("id")) }.count
|
||||
+ vm.content.mat4.filter { vm.progress.mat4Done($0.int("id")) }.count
|
||||
case "csyears", "csmap", "cs2map", "cs3map", "cs4map":
|
||||
sum.totalEx = vm.content.cs.count + vm.content.cs2.count + vm.content.cs3.count + vm.content.cs4.count
|
||||
sum.doneEx = vm.content.cs.filter { vm.progress.csDone($0.int("id")) }.count
|
||||
+ vm.content.cs2.filter { vm.progress.cs2Done($0.int("id")) }.count
|
||||
+ vm.content.cs3.filter { vm.progress.cs3Done($0.int("id")) }.count
|
||||
+ vm.content.cs4.filter { vm.progress.cs4Done($0.int("id")) }.count
|
||||
case "tgyears", "tgmap", "tg2map", "tg3map", "tg4map":
|
||||
sum.totalEx = vm.content.tg.count + vm.content.tg2.count + vm.content.tg3.count + vm.content.tg4.count
|
||||
sum.doneEx = vm.content.tg.filter { vm.progress.tgDone($0.int("id")) }.count
|
||||
|
|
@ -1042,6 +1048,23 @@ private func buildSearch(_ vm: AppModel) -> [Hit] {
|
|||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "tg4unit\(l.int("id"))", extra: "autocad")
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "tg4ex\(l.int("id"))")
|
||||
}
|
||||
add(vm.tr("Programování"), vm.tr("search_lesson"), "csyears", extra: "csharp c#")
|
||||
add(vm.tr("cs_year1"), vm.tr("search_lesson"), "csmap", extra: "rocnik")
|
||||
add(vm.tr("cs_year2"), vm.tr("search_lesson"), "cs2map", extra: "rocnik")
|
||||
add(vm.tr("cs_year3"), vm.tr("search_lesson"), "cs3map", extra: "rocnik")
|
||||
add(vm.tr("cs_year4"), vm.tr("search_lesson"), "cs4map", extra: "rocnik")
|
||||
for l in vm.content.cs {
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "csunit\(l.int("id"))", extra: "csharp")
|
||||
}
|
||||
for l in vm.content.cs2 {
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs2unit\(l.int("id"))", extra: "csharp")
|
||||
}
|
||||
for l in vm.content.cs3 {
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs3unit\(l.int("id"))", extra: "csharp")
|
||||
}
|
||||
for l in vm.content.cs4 {
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "cs4unit\(l.int("id"))", extra: "csharp")
|
||||
}
|
||||
add(vm.tr("Chemie"), vm.tr("search_lesson"), "chemmap", extra: "chemie")
|
||||
add(vm.tr("Biologie"), vm.tr("search_lesson"), "biomap", extra: "biologie")
|
||||
for l in vm.content.chem {
|
||||
|
|
@ -1776,6 +1799,47 @@ struct MatYearsScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct CsYearsScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
let years: [(String, String, Route)] = [
|
||||
("cs_year1", "cs_sub", .csMap),
|
||||
("cs_year2", "cs2_sub", .cs2Map),
|
||||
("cs_year3", "cs3_sub", .cs3Map),
|
||||
("cs_year4", "cs4_sub", .cs4Map),
|
||||
]
|
||||
MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in
|
||||
NavigationLink(value: years[i].2) {
|
||||
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
|
||||
}
|
||||
.buttonStyle(PressScaleStyle())
|
||||
}
|
||||
.navigationTitle(vm.tr("Programování"))
|
||||
.navigationSubtitle(vm.tr("cs_years_sub"))
|
||||
#else
|
||||
List {
|
||||
NavigationLink(value: Route.csMap) {
|
||||
ListRowLabel(title: vm.tr("cs_year1"), subtitle: vm.tr("cs_sub"))
|
||||
}
|
||||
NavigationLink(value: Route.cs2Map) {
|
||||
ListRowLabel(title: vm.tr("cs_year2"), subtitle: vm.tr("cs2_sub"))
|
||||
}
|
||||
NavigationLink(value: Route.cs3Map) {
|
||||
ListRowLabel(title: vm.tr("cs_year3"), subtitle: vm.tr("cs3_sub"))
|
||||
}
|
||||
NavigationLink(value: Route.cs4Map) {
|
||||
ListRowLabel(title: vm.tr("cs_year4"), subtitle: vm.tr("cs4_sub"))
|
||||
}
|
||||
}
|
||||
.navigationTitle(vm.tr("Programování"))
|
||||
.navigationSubtitle(vm.tr("cs_years_sub"))
|
||||
.appListStyle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct TgYearsScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
|
|
@ -2876,6 +2940,64 @@ struct RouteDestination: View {
|
|||
}
|
||||
case .fyz4Ex(let id):
|
||||
FyzQuizScreen(vm: vm, year: 4, id: id)
|
||||
case .csYears:
|
||||
CsYearsScreen(vm: vm)
|
||||
case .csMap:
|
||||
LessonListScreen(
|
||||
vm: vm,
|
||||
title: vm.tr("cs_year1"),
|
||||
subtitle: vm.tr("cs_sub"),
|
||||
rows: vm.content.cs.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(id: "c\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.csDone(id), destination: .csLesson(id))
|
||||
}
|
||||
)
|
||||
case .csLesson(let id):
|
||||
if let l = vm.content.csLesson(id) {
|
||||
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs(id) })
|
||||
}
|
||||
case .cs2Map:
|
||||
LessonListScreen(
|
||||
vm: vm,
|
||||
title: vm.tr("cs_year2"),
|
||||
subtitle: vm.tr("cs2_sub"),
|
||||
rows: vm.content.cs2.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(id: "c2\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs2Done(id), destination: .cs2Lesson(id))
|
||||
}
|
||||
)
|
||||
case .cs2Lesson(let id):
|
||||
if let l = vm.content.cs2Lesson(id) {
|
||||
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs2(id) })
|
||||
}
|
||||
case .cs3Map:
|
||||
LessonListScreen(
|
||||
vm: vm,
|
||||
title: vm.tr("cs_year3"),
|
||||
subtitle: vm.tr("cs3_sub"),
|
||||
rows: vm.content.cs3.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(id: "c3\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs3Done(id), destination: .cs3Lesson(id))
|
||||
}
|
||||
)
|
||||
case .cs3Lesson(let id):
|
||||
if let l = vm.content.cs3Lesson(id) {
|
||||
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs3(id) })
|
||||
}
|
||||
case .cs4Map:
|
||||
LessonListScreen(
|
||||
vm: vm,
|
||||
title: vm.tr("cs_year4"),
|
||||
subtitle: vm.tr("cs4_sub"),
|
||||
rows: vm.content.cs4.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(id: "c4\(id)", title: vm.tr(l.str("titleKey")), done: vm.progress.cs4Done(id), destination: .cs4Lesson(id))
|
||||
}
|
||||
)
|
||||
case .cs4Lesson(let id):
|
||||
if let l = vm.content.cs4Lesson(id) {
|
||||
CodeLessonScreen(vm: vm, lesson: l, onSuccess: { vm.markCs4(id) })
|
||||
}
|
||||
case .tgYears:
|
||||
TgYearsScreen(vm: vm)
|
||||
case .tgMap:
|
||||
|
|
@ -2970,3 +3092,251 @@ struct RouteDestination: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CodeLessonScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let lesson: J
|
||||
let onSuccess: () -> Void
|
||||
@State private var code = ""
|
||||
@State private var stdin = ""
|
||||
@State private var output = ""
|
||||
@State private var running = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if geo.size.width >= 840 {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
explanation.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
editor.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
outputPane.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
explanation
|
||||
editor.frame(minHeight: 240)
|
||||
outputPane.frame(minHeight: 160)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.detailScreen(vm.tr(lesson.str("titleKey")), subtitle: vm.tr(lesson.str("subKey")))
|
||||
.onAppear {
|
||||
if code.isEmpty { code = lesson.str("code") }
|
||||
}
|
||||
}
|
||||
|
||||
private var explanation: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(Array(lesson.arr("slides").enumerated()), id: \.offset) { _, slide in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(slide.str("kicker"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(slide.str("title"))
|
||||
.font(.title3.bold())
|
||||
ForEach(slide.strs("lines"), id: \.self) { line in
|
||||
Text("• \(line)")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let tip = slide.strOrNull("tip") {
|
||||
Text(tip)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var editor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(vm.tr("cs_code")).font(.headline)
|
||||
TextEditor(text: $code)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(codeFill, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(vm.tr("cs_stdin"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("", text: $stdin, axis: .vertical)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(2...4)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private var outputPane: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Button(vm.tr("cs_run")) { run() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(running)
|
||||
Button(vm.tr("cs_reset")) { code = lesson.str("code") }
|
||||
.disabled(running)
|
||||
}
|
||||
Text(vm.tr("cs_output")).font(.headline)
|
||||
ScrollView {
|
||||
Text(output.isEmpty ? vm.tr("cs_empty") : output)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(codeFill, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var codeFill: Color {
|
||||
#if os(macOS)
|
||||
Color(.textBackgroundColor).opacity(0.55)
|
||||
#else
|
||||
Color(.secondarySystemBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func run() {
|
||||
running = true
|
||||
output = vm.tr("cs_running")
|
||||
CSharpRunner.run(code, stdin: stdin) { status, text in
|
||||
running = false
|
||||
switch status {
|
||||
case "unavailable":
|
||||
output = vm.tr("cs_unavailable")
|
||||
case "timeout":
|
||||
output = (text + "\n\n" + vm.tr("cs_timeout")).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
default:
|
||||
output = text.isEmpty ? vm.tr("cs_empty") : text
|
||||
}
|
||||
if status == "ok" { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CSharpRunner {
|
||||
static func run(_ source: String, stdin: String, done: @escaping (String, String) -> Void) {
|
||||
#if os(macOS)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result = runMac(source, stdin: stdin)
|
||||
DispatchQueue.main.async { done(result.0, result.1) }
|
||||
}
|
||||
#else
|
||||
done("unavailable", "")
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private static func runMac(_ source: String, stdin: String) -> (String, String) {
|
||||
guard source.count <= 100_000 else { return ("fail", "Program je příliš dlouhý.") }
|
||||
guard let dotnet = findDotnet() else { return ("unavailable", "") }
|
||||
let dir = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".graduately/csharp-lab")
|
||||
let csproj = dir.appendingPathComponent("Lab.csproj")
|
||||
if !FileManager.default.fileExists(atPath: csproj.path) {
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let framework = targetFramework(dotnet)
|
||||
let xml = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>\(framework)</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
"""
|
||||
try? xml.write(to: csproj, atomically: true, encoding: .utf8)
|
||||
let restored = command(dotnet, ["restore", dir.path], dir, "", 90)
|
||||
if restored.0 != "ok" { return restored }
|
||||
}
|
||||
do {
|
||||
try source.write(to: dir.appendingPathComponent("Program.cs"), atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
return ("fail", error.localizedDescription)
|
||||
}
|
||||
var args = ["run", "--project", dir.path, "--verbosity", "quiet"]
|
||||
if FileManager.default.fileExists(atPath: dir.appendingPathComponent("obj").path) {
|
||||
args.append("--no-restore")
|
||||
}
|
||||
return command(dotnet, args, dir, stdin, 20)
|
||||
}
|
||||
|
||||
private static func command(_ dotnet: String, _ args: [String], _ dir: URL, _ stdin: String, _ seconds: Double) -> (String, String) {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: dotnet)
|
||||
proc.arguments = args
|
||||
proc.currentDirectoryURL = dir
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
let root = (dotnet as NSString).deletingLastPathComponent
|
||||
env["PATH"] = [root, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].joined(separator: ":")
|
||||
env["DOTNET_ROOT"] = root
|
||||
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
|
||||
env["DOTNET_NOLOGO"] = "1"
|
||||
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
|
||||
proc.environment = env
|
||||
let out = Pipe()
|
||||
let input = Pipe()
|
||||
proc.standardOutput = out
|
||||
proc.standardError = out
|
||||
proc.standardInput = input
|
||||
var collected = Data()
|
||||
let lock = NSLock()
|
||||
out.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
guard !chunk.isEmpty else { return }
|
||||
lock.lock()
|
||||
collected.append(chunk)
|
||||
lock.unlock()
|
||||
}
|
||||
do { try proc.run() } catch { return ("fail", error.localizedDescription) }
|
||||
if let data = stdin.data(using: .utf8) { input.fileHandleForWriting.write(data) }
|
||||
try? input.fileHandleForWriting.close()
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
proc.terminationHandler = { _ in group.leave() }
|
||||
if group.wait(timeout: .now() + seconds) == .timedOut {
|
||||
proc.terminate()
|
||||
kill(proc.processIdentifier, SIGKILL)
|
||||
out.fileHandleForReading.readabilityHandler = nil
|
||||
return ("timeout", clip(collected, lock))
|
||||
}
|
||||
out.fileHandleForReading.readabilityHandler = nil
|
||||
let rest = out.fileHandleForReading.readDataToEndOfFile()
|
||||
lock.lock()
|
||||
collected.append(rest)
|
||||
lock.unlock()
|
||||
let text = clip(collected, lock)
|
||||
return proc.terminationStatus == 0 ? ("ok", text) : ("fail", text)
|
||||
}
|
||||
|
||||
private static func clip(_ data: Data, _ lock: NSLock) -> String {
|
||||
lock.lock()
|
||||
let copy = data
|
||||
lock.unlock()
|
||||
let text = String(data: copy, encoding: .utf8) ?? ""
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.count <= 16_000 ? trimmed : String(trimmed.prefix(16_000)) + "\n…"
|
||||
}
|
||||
|
||||
private static func targetFramework(_ dotnet: String) -> String {
|
||||
let version = command(dotnet, ["--version"], URL(fileURLWithPath: NSTemporaryDirectory()), "", 20)
|
||||
let major = version.1.split(separator: ".").first.map(String.init) ?? ""
|
||||
return major.allSatisfy(\.isNumber) && !major.isEmpty ? "net\(major).0" : "net8.0"
|
||||
}
|
||||
|
||||
private static func findDotnet() -> String? {
|
||||
let names = [
|
||||
"/usr/local/share/dotnet/dotnet",
|
||||
"/opt/homebrew/share/dotnet/dotnet",
|
||||
"/usr/local/bin/dotnet",
|
||||
NSHomeDirectory() + "/.dotnet/dotnet",
|
||||
]
|
||||
return names.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue