Simplify page view file loading to plain relative paths
This commit is contained in:
@@ -34,8 +34,7 @@ enum Page: Hashable {
|
||||
@MainActor
|
||||
func url(_ store: Store) -> URL? {
|
||||
switch self {
|
||||
case .file(let taskId, let path):
|
||||
path.hasPrefix("/") ? store.pages.url(path) : store.findTask(taskId).flatMap { store.pages.url("\($0.worktreePath)/\(path)") }
|
||||
case .file(let taskId, let path): store.pages.url(taskId, path)
|
||||
case .preview(let taskId, let port): store.forwards.url(taskId, port)
|
||||
case .web: store.pages.existing(self)?.url
|
||||
}
|
||||
@@ -140,7 +139,7 @@ extension Store {
|
||||
final class Pages {
|
||||
private(set) var port: Int?
|
||||
private(set) var titles: [Page: String] = [:]
|
||||
@ObservationIgnored var served: [String: Data] = [:]
|
||||
@ObservationIgnored var served: [String: [String: Data]] = [:]
|
||||
@ObservationIgnored var popup: ((Page) -> Void)?
|
||||
@ObservationIgnored private var listener: PageListener?
|
||||
@ObservationIgnored private var views: [Page: WKWebView] = [:]
|
||||
@@ -158,14 +157,14 @@ final class Pages {
|
||||
Task { @MainActor in
|
||||
self?.port = port
|
||||
}
|
||||
}, served: { [weak self] path, body in
|
||||
}, served: { [weak self] taskId, path, body in
|
||||
Task { @MainActor in
|
||||
self?.served[path] = body
|
||||
self?.served[taskId, default: [:]][path] = body
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func url(_ path: String) -> URL? {
|
||||
func url(_ taskId: String, _ path: String) -> URL? {
|
||||
guard let port else {
|
||||
return nil
|
||||
}
|
||||
@@ -173,7 +172,7 @@ final class Pages {
|
||||
c.scheme = "http"
|
||||
c.host = "127.0.0.1"
|
||||
c.port = port
|
||||
c.path = path
|
||||
c.path = "/\(taskId)/\(path)"
|
||||
return c.url
|
||||
}
|
||||
|
||||
@@ -254,16 +253,6 @@ final class Pages {
|
||||
return "Loaded \(view.url?.absoluteString ?? url.absoluteString): \(view.title ?? "")"
|
||||
}
|
||||
|
||||
func loaded(_ view: WKWebView) async -> Set<String> {
|
||||
let urls = (try? await view.callAsyncJavaScript("return [location.href, ...performance.getEntriesByType('resource').map(e => e.name)]", contentWorld: .defaultClient)) as? [String] ?? []
|
||||
return Set(urls.compactMap { s -> String? in
|
||||
guard let c = URLComponents(string: s), c.host == "127.0.0.1", c.port == port else {
|
||||
return nil
|
||||
}
|
||||
return c.path.hasSuffix("/") ? "\(c.path)index.html" : c.path
|
||||
})
|
||||
}
|
||||
|
||||
func js(_ view: WKWebView, _ code: String) async throws -> String {
|
||||
let value = try await view.callAsyncJavaScript(code, contentWorld: .page)
|
||||
guard let value, !(value is NSNull) else {
|
||||
@@ -308,9 +297,9 @@ final class Pages {
|
||||
port = nil
|
||||
}
|
||||
|
||||
static func fetch(_ api: API, _ path: String) async -> (status: Int, type: String, body: Data) {
|
||||
static func fetch(_ api: API, _ taskId: String, _ path: String) async -> (status: Int, type: String, body: Data) {
|
||||
do {
|
||||
let (body, response) = try await api.session.data(from: api.url("/api/raw", [URLQueryItem(name: "path", value: path)]))
|
||||
let (body, response) = try await api.session.data(from: api.url("/api/tasks/\(taskId)/raw", [URLQueryItem(name: "path", value: path)]))
|
||||
let http = response as! HTTPURLResponse
|
||||
return (http.statusCode, http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream", body)
|
||||
} catch {
|
||||
@@ -326,12 +315,12 @@ private final class PageListener: @unchecked Sendable {
|
||||
private let api: API
|
||||
private let queue = DispatchQueue(label: "run.fc.byre.pages")
|
||||
private let ready: @Sendable (Int) -> Void
|
||||
private let served: @Sendable (String, Data) -> Void
|
||||
private let served: @Sendable (String, String, Data) -> Void
|
||||
private var listener: NWListener?
|
||||
private var port: UInt16 = 0
|
||||
private var closed = false
|
||||
|
||||
init(api: API, ready: @escaping @Sendable (Int) -> Void, served: @escaping @Sendable (String, Data) -> Void) {
|
||||
init(api: API, ready: @escaping @Sendable (Int) -> Void, served: @escaping @Sendable (String, String, Data) -> Void) {
|
||||
self.api = api
|
||||
self.ready = ready
|
||||
self.served = served
|
||||
@@ -451,14 +440,18 @@ private final class PageListener: @unchecked Sendable {
|
||||
respond(conn, 400, "text/plain; charset=utf-8", Data("bad request".utf8))
|
||||
return
|
||||
}
|
||||
let file = path.hasSuffix("/") ? "\(path)index.html" : path
|
||||
let route = path.drop(while: { $0 == "/" }).split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard route.count == 2, !route[1].isEmpty else {
|
||||
respond(conn, 404, "text/plain; charset=utf-8", Data("not found".utf8))
|
||||
return
|
||||
}
|
||||
let taskId = String(route[0])
|
||||
let file = route[1].hasSuffix("/") ? "\(route[1])index.html" : String(route[1])
|
||||
let api = api
|
||||
Task {
|
||||
let (status, type, body) = await Pages.fetch(api, file)
|
||||
self.served(file, body)
|
||||
// Agents write file:///abs/path references, which a page served over http can't load; /abs/path serves the same file here.
|
||||
let shown = type.hasPrefix("text/html") || type.hasPrefix("text/css") ? body.replacing(Data("file://".utf8), with: Data()) : body
|
||||
self.respond(conn, status, type, shown)
|
||||
let (status, type, body) = await Pages.fetch(api, taskId, file)
|
||||
self.served(taskId, file, body)
|
||||
self.respond(conn, status, type, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,30 +466,26 @@ private final class PageListener: @unchecked Sendable {
|
||||
@MainActor
|
||||
@Observable
|
||||
private final class PageWatch {
|
||||
private(set) var reloads: [Page: Int] = [:]
|
||||
private(set) var reloads = 0
|
||||
@ObservationIgnored private var timer: Task<Void, Never>?
|
||||
|
||||
func check(_ api: API, _ pages: Pages, _ views: [Page: WKWebView]) {
|
||||
func check(_ api: API, _ pages: Pages, _ taskId: String) {
|
||||
timer?.cancel()
|
||||
timer = Task {
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
var loaded: [Page: Set<String>] = [:]
|
||||
for (page, view) in views {
|
||||
loaded[page] = await pages.loaded(view)
|
||||
}
|
||||
var fresh: [String: Data] = [:]
|
||||
for path in loaded.values.reduce(Set(), { $0.union($1) }) {
|
||||
fresh[path] = await Pages.fetch(api, path).body
|
||||
var changed = false
|
||||
for (path, shown) in pages.served[taskId] ?? [:] {
|
||||
let fresh = await Pages.fetch(api, taskId, path).body
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
if fresh != shown {
|
||||
pages.served[taskId]?[path] = fresh
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
let changed = Set(fresh.filter { pages.served[$0.key] != $0.value }.keys)
|
||||
for (path, body) in fresh {
|
||||
pages.served[path] = body
|
||||
}
|
||||
for (page, paths) in loaded where !paths.isDisjoint(with: changed) {
|
||||
reloads[page, default: 0] += 1
|
||||
if changed {
|
||||
reloads += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,7 +518,7 @@ struct PageTabs: View {
|
||||
ZStack {
|
||||
ForEach(shown.pages, id: \.self) { page in
|
||||
if let view = store.pageView(page) {
|
||||
WebPage(view: view, reloads: watch.reloads[page] ?? 0, shown: page == shown.current, refresh: page.isFile)
|
||||
WebPage(view: view, reloads: page.isFile ? watch.reloads : 0, shown: page == shown.current, refresh: page.isFile)
|
||||
} else if page == shown.current {
|
||||
EmptyText(text: "Loading…")
|
||||
}
|
||||
@@ -539,12 +528,11 @@ struct PageTabs: View {
|
||||
.task(id: shown.current.taskId) {
|
||||
let taskId = shown.current.taskId
|
||||
let watch = watch
|
||||
let store = store
|
||||
let api = store.api
|
||||
let pages = store.pages
|
||||
let off = store.selectedFeed?.appended.add { events in
|
||||
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
|
||||
let files = (store.shownPages[taskId]?.pages ?? []).filter(\.isFile)
|
||||
let views = Dictionary(uniqueKeysWithValues: files.compactMap { page in store.pages.existing(page).map { (page, $0) } })
|
||||
watch.check(store.api, store.pages, views)
|
||||
watch.check(api, pages, taskId)
|
||||
}
|
||||
}
|
||||
await untilCancelled()
|
||||
|
||||
Reference in New Issue
Block a user