Add a project Page tab and a page origin per worktree and repo

This commit is contained in:
Ian Gulliver
2026-10-23 19:33:26 -07:00
parent 1f61bf7cf5
commit c52b8839bd
12 changed files with 327 additions and 161 deletions
+16
View File
@@ -1156,6 +1156,22 @@ paths:
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/raw:
get:
operationId: getRaw
description: >
A regular file anywhere on the server's disk, whole, with a content type chosen by its extension, as getTaskRaw
serves it.
parameters:
- { name: path, in: query, required: true, schema: { type: string }, description: "Slash-separated and absolute." }
responses:
"200":
description: The file's bytes.
content:
"*/*": { schema: { type: string, format: binary } }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
/api/blobs:
post:
operationId: uploadBlob
+3 -3
View File
@@ -4,7 +4,7 @@ Platform constraints, build requirements and SwiftUI behavior observed live.
## Decision
**One web view only**: a task's Page view, which shows an HTML file from the task's worktree, one of its exposed previews, or a website an agent opened with `show_page`'s `url`, in a `WKWebView`, in the attached sheet over the lead pane. Nothing else renders HTML: no `WKWebView` or `WebView` in the window, a dialog, Markdown, diagrams or the terminal. Any other link to a web page opens in the default browser. Mermaid needs a browser DOM, and its automatic layout can't give the banded, nested look of a hand-drawn architecture picture, so byre has its own ```` ```diagram ```` block (rows, nested boxes, a side column, routed arrows, named highlights), laid out on a grid by the app; its syntax is the `diagrams` knowledge-base article. It is byre's only diagram format: a ```` ```mermaid ```` block shows as code, and the workflow stage graphs are drawn by generating its source. Agents see their diagrams through the `render_diagram` tool, which runs the installed app's own binary as `Byre render-diagram` (source on stdin, PNG on stdout), so the picture is exactly what the transcript draws; that mode opens no window and never touches the app's iCloud-synced store, so it uses the default text size. byre's Markdown dialect (choice markers, `<details>` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself.
**One web view only**: the Page view, which shows an HTML file from a task's worktree, one of its exposed previews, or a website an agent opened with `show_page`'s `url`, in a `WKWebView`, in the attached sheet over the lead pane; and an HTML file from a project's repository in the project page's Page tab. Nothing else renders HTML: no `WKWebView` or `WebView` in the window, a dialog, Markdown, diagrams or the terminal. Any other link to a web page opens in the default browser. Mermaid needs a browser DOM, and its automatic layout can't give the banded, nested look of a hand-drawn architecture picture, so byre has its own ```` ```diagram ```` block (rows, nested boxes, a side column, routed arrows, named highlights), laid out on a grid by the app; its syntax is the `diagrams` knowledge-base article. It is byre's only diagram format: a ```` ```mermaid ```` block shows as code, and the workflow stage graphs are drawn by generating its source. Agents see their diagrams through the `render_diagram` tool, which runs the installed app's own binary as `Byre render-diagram` (source on stdin, PNG on stdout), so the picture is exactly what the transcript draws; that mode opens no window and never touches the app's iCloud-synced store, so it uses the default text size. byre's Markdown dialect (choice markers, `<details>` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself.
The app joins the tailnet as its own node with libtailscale and reaches byre's HTTPS origin on byre's node, so byre knows who is calling from the node itself. It never sends identity headers, and certificate validation is never relaxed.
@@ -39,8 +39,8 @@ Every build is signed for distribution outside the App Store: the team's Develop
- A plain `.background(color)` on a view inside a container with a shaped background (`.background(_, in: RoundedRectangle(...))`) fills a smaller rounded shape concentric with the container's, not its own rectangle, on macOS 27 and iOS 27. Stacked rows each drawn that way get notched ends, as the Changes view's diff lines did. Give a row's fill `in: Rectangle()` when it must reach its edges square.
- A full-size diagram is drawn at its zoom, in tiles, and a pinch is folded into that zoom when it ends. Scaling a natural-size SwiftUI view down with `NSScrollView` magnification or `UIScrollView` zooming draws only about a window's width of it, so fitting a wide diagram that way shows its first few boxes and blank space.
- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port (`http://127.0.0.1:<port>/<task id>/<path>`), which fetches each file through the server's `getTaskRaw` endpoint. The page's relative links to its stylesheets, scripts and images resolve to files beside it; absolute and `file://` references don't load. "↗ Browser" opens the same address. iOS reclaims the listening socket while the app is suspended, so the app binds it again on the same port each time it returns to the foreground. Previews load from their loopback forwards at `<name>-<task>.localhost`. App Transport Security blocks plain HTTP to `*.localhost` even with `NSAllowsLocalNetworking`; the `localhost` exception domain with subdomains in `Info.plist` allows it (loopback IP addresses need no exception). `NSAllowsArbitraryLoadsInWebContent` lets the Page view load plain-HTTP websites without lifting ATS for the app's own requests.
- Each Page view tab's `WKWebView` belongs to `Pages`, not to the SwiftUI view, so it keeps its page while the sheet is closed and agents can drive it with `drive_page` (navigate, JavaScript, capture) from the client connection. All tabs share WebKit's default persistent data store, so a site the human signs into stays signed in across launches. A link that opens a new window becomes a new tab of the same task. Capture refuses a tab that isn't on screen (sheet closed, or another tab showing), since its snapshot would show nothing the human sees. Passkey sign-in may fail, because WebKit allows WebAuthn in an embedded view only for apps with Apple's web browser entitlement; a password with a 2FA code works.
- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port, which fetches each file through the server's `getTaskRaw`, `getProjectRaw` or `getRaw` endpoint. Each task's worktree and each project's repository is its own host, named by its ID (`http://<id>.localhost:<port>/<path>`); every file outside them is served from one host for the whole disk (`disk.localhost`, through `getRaw`). macOS's resolver answers every `*.localhost` name with loopback itself (tools that query DNS directly, like `host` and `dig`, get NXDOMAIN), and WebKit sends the host lowercased, so the server finds the site by the lowercased name. Each host is its own origin: a page's relative references resolve to files beside it, and its `/…` references to the worktree or repository root. Files outside the worktree or repository are served at their absolute path, so `/…` there is the disk root. `file://` references don't load. "↗ Browser" opens the same address. iOS reclaims the listening socket while the app is suspended, so the app binds it again on the same port each time it returns to the foreground. Previews load from their loopback forwards at `<name>-<task>.localhost`. App Transport Security blocks plain HTTP to `*.localhost` even with `NSAllowsLocalNetworking`; the `localhost` exception domain with subdomains in `Info.plist` allows it (loopback IP addresses need no exception). `NSAllowsArbitraryLoadsInWebContent` lets the Page view load plain-HTTP websites without lifting ATS for the app's own requests.
- Each Page view tab's `WKWebView` belongs to `Pages`, not to the SwiftUI view, so it keeps its page while the sheet is closed and agents can drive it with `drive_page` (navigate, JavaScript, capture) from the client connection. All tabs share WebKit's default persistent data store, so a site the human signs into stays signed in across launches. A link that opens a new window becomes a new tab of the same task or project. Capture refuses a tab that isn't on screen (sheet closed, or another tab showing), since its snapshot would show nothing the human sees. Passkey sign-in may fail, because WebKit allows WebAuthn in an embedded view only for apps with Apple's web browser entitlement; a password with a 2FA code works.
## Updates
+122 -78
View File
@@ -1904,6 +1904,12 @@ type GetProjectRawParams struct {
Path string `form:"path" json:"path"`
}
// GetRawParams defines parameters for GetRaw.
type GetRawParams struct {
// Path Slash-separated and absolute.
Path string `form:"path" json:"path"`
}
// GetTaskChangesParams defines parameters for GetTaskChanges.
type GetTaskChangesParams struct {
// Since A commit to compare with instead of the merge-base, such as a user.message event's head. Not allowed with snapshot.
@@ -2222,6 +2228,9 @@ type ServerInterface interface {
// (POST /api/projects/{projectId}/todo/tasks)
StartTodoItem(w http.ResponseWriter, r *http.Request, projectId string)
// (GET /api/raw)
GetRaw(w http.ResponseWriter, r *http.Request, params GetRawParams)
// (PUT /api/reviews/{reviewId}/findings/{index})
SetFindingState(w http.ResponseWriter, r *http.Request, reviewId string, index int)
@@ -3411,6 +3420,39 @@ func (siw *ServerInterfaceWrapper) StartTodoItem(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
// GetRaw operation middleware
func (siw *ServerInterfaceWrapper) GetRaw(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// Parameter object where we will unmarshal all parameters from the context
var params GetRawParams
// ------------- Required query parameter "path" -------------
err = runtime.BindQueryParameterWithOptions("form", true, true, "path", r.URL.Query(), &params.Path, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err})
}
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetRaw(w, r, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SetFindingState operation middleware
func (siw *ServerInterfaceWrapper) SetFindingState(w http.ResponseWriter, r *http.Request) {
@@ -4855,6 +4897,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/pause", wrapper.PauseSession)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/resume", wrapper.ResumeSession)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/raw", wrapper.GetRaw)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/blobs", wrapper.UploadBlob)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox)
@@ -5173,84 +5216,85 @@ var swaggerSpec = []string{
"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",
"/CqAKbT9crWKRiSwfGp8wAC5RNa/MbXsx4///wA=",
"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",
"2Y8f//8BAA==",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+8
View File
@@ -240,6 +240,14 @@ func (s *Server) GetProjectRaw(w http.ResponseWriter, r *http.Request, projectID
serveRaw(w, r, p.RepoPath, params.Path)
}
func (s *Server) GetRaw(w http.ResponseWriter, r *http.Request, params api.GetRawParams) {
if !path.IsAbs(params.Path) {
writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not an absolute path", params.Path))
return
}
serveRaw(w, r, "/", params.Path)
}
func serveRaw(w http.ResponseWriter, r *http.Request, root, rel string) {
if rel == "" {
writeError(w, http.StatusBadRequest, errors.New("path is required"))
+1 -2
View File
@@ -39,7 +39,7 @@ enum LeftTab: String {
}
enum ProjectTab {
case tasks, files, settings
case tasks, files, page, settings
}
@MainActor
@@ -60,7 +60,6 @@ final class Overlays {
@ObservationIgnored var endFilter: (token: String, run: () -> Void)?
var listKeys = false
var listCursor: String?
var projectTab = ProjectTab.tasks
var taskList = false
@ObservationIgnored var pickerFrame: CGRect = .zero
@ObservationIgnored var menuHover = false
+10 -6
View File
@@ -43,7 +43,7 @@ extension Store {
func showPage(_ taskId: String, _ path: String) {
route("#/task/\(taskId)")
openPage(.file(taskId: taskId, path: path))
openPage(.file(.task(taskId), path: path))
}
func showInFiles(_ taskId: String, _ path: String) {
@@ -51,10 +51,14 @@ extension Store {
route("#/task/\(taskId)/files")
}
func showInProjectFiles(_ projectId: String, _ path: String, _ overlays: Overlays) {
filePlaces[.project(projectId)] = filePlace(at: path)
overlays.projectTab = .files
func openProjectFile(_ projectId: String, _ path: String) {
route("#/project/\(projectId)")
if isPage(path) {
openPage(.file(.project(projectId), path: path))
return
}
filePlaces[.project(projectId)] = filePlace(at: path)
projectTab = .files
}
private func filePlace(at path: String) -> FilePlace {
@@ -392,8 +396,8 @@ private struct FilesBody: View {
EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink, save: e._type == .file ? { model.save(join(l.path, e.name)) } : nil) {
if e._type == .dir {
model.go(FilePlace(dir: join(l.path, e.name)))
} else if e._type == .file && isPage(e.name), case .task(let taskId) = model.root {
store.openPage(.file(taskId: taskId, path: join(l.path, e.name)))
} else if e._type == .file && isPage(e.name), let root = model.root {
store.openPage(.file(root, path: join(l.path, e.name)))
} else if e._type == .file {
model.go(FilePlace(dir: l.path, file: join(l.path, e.name)))
}
+1 -1
View File
@@ -53,7 +53,7 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
store.route(q["hash"] ?? "")
case "file":
if let project = q["project"] {
store.showInProjectFiles(project, q["path"] ?? "", overlays)
store.openProjectFile(project, q["path"] ?? "")
} else {
store.openFile(q["task"] ?? "", q["path"] ?? "")
}
+128 -56
View File
@@ -5,13 +5,14 @@ import SwiftUI
import WebKit
enum Page: Hashable {
case file(taskId: String, path: String)
case file(FileRoot, path: String)
case preview(taskId: String, port: Int)
case web(taskId: String, id: Int)
case web(FileRoot, id: Int)
var taskId: String {
var root: FileRoot {
switch self {
case .file(let taskId, _), .preview(let taskId, _), .web(let taskId, _): taskId
case .file(let root, _), .web(let root, _): root
case .preview(let taskId, _): .task(taskId)
}
}
@@ -34,13 +35,23 @@ enum Page: Hashable {
@MainActor
func url(_ store: Store) -> URL? {
switch self {
case .file(let taskId, let path): store.pages.url(taskId, path)
case .file(let root, let path): store.pages.url(root, path)
case .preview(let taskId, let port): store.forwards.url(taskId, port)
case .web: store.pages.existing(self)?.url
}
}
}
// Each site is its own loopback origin, so a page's "/…" references resolve against its worktree or repository root.
enum PageSite: Hashable {
case root(FileRoot)
case disk
init(_ root: FileRoot, _ path: String) {
self = path.hasPrefix("/") ? .disk : .root(root)
}
}
private let pageExts: Set<String> = ["html", "htm"]
func isPage(_ path: String) -> Bool {
@@ -54,43 +65,61 @@ struct ShownPages {
extension Store {
func openPage(_ page: Page) {
var shown = shownPages[page.taskId] ?? ShownPages(pages: [], current: page)
var shown = shownPages[page.root] ?? ShownPages(pages: [], current: page)
if !shown.pages.contains(page) {
shown.pages.append(page)
}
shown.current = page
shownPages[page.taskId] = shown
sheet = Sheet(owner: .task(page.taskId), view: .tab(.page))
shownPages[page.root] = shown
switch page.root {
case .task(let taskId): sheet = Sheet(owner: .task(taskId), view: .tab(.page))
case .project: projectTab = .page
}
}
func openWeb(_ taskId: String, _ url: URL) -> Int {
route("#/task/\(taskId)")
let page = pages.web(taskId, url)
let page = pages.web(.task(taskId), url)
openPage(page)
return pages.tab(page)
}
func closePage(_ page: Page) {
guard var shown = shownPages[page.taskId], let i = shown.pages.firstIndex(of: page) else {
guard var shown = shownPages[page.root], let i = shown.pages.firstIndex(of: page) else {
return
}
pages.drop(page)
shown.pages.remove(at: i)
if shown.pages.isEmpty {
shownPages[page.taskId] = nil
if sheet?.tab == .page {
sheet = nil
shownPages[page.root] = nil
pages.closeSites(page.root)
switch page.root {
case .task:
if sheet?.tab == .page {
sheet = nil
}
case .project:
if projectTab == .page {
projectTab = .files
}
}
return
}
if shown.current == page {
shown.current = shown.pages[min(i, shown.pages.count - 1)]
}
shownPages[page.taskId] = shown
shownPages[page.root] = shown
}
func shownProjectTab() -> ProjectTab {
if projectTab == .page, focusedProject().flatMap({ shownPages[.project($0.id)] }) == nil {
return .tasks
}
return projectTab
}
func currentPage() -> Page? {
selectedTaskId.flatMap { shownPages[$0]?.current }
selectedTaskId.flatMap { shownPages[.task($0)]?.current }
}
func pageView(_ page: Page) -> WKWebView? {
@@ -101,7 +130,7 @@ extension Store {
}
func pageTabs(_ taskId: String) -> String {
guard let shown = shownPages[taskId] else {
guard let shown = shownPages[.task(taskId)] else {
return "This task's Page view has no tabs; open one with show_page."
}
return shown.pages.map { page in
@@ -117,7 +146,7 @@ extension Store {
}
func drivenPage(_ taskId: String, _ tab: Int?) throws -> WKWebView {
guard let shown = shownPages[taskId] else {
guard let shown = shownPages[.task(taskId)] else {
throw ApiError(status: 0, message: "this task's Page view has no tabs; open one with show_page")
}
var page = shown.current
@@ -139,9 +168,11 @@ extension Store {
final class Pages {
private(set) var port: Int?
private(set) var titles: [Page: String] = [:]
@ObservationIgnored var served: [String: [String: Data]] = [:]
@ObservationIgnored var served: [PageSite: [String: Data]] = [:]
@ObservationIgnored var popup: ((Page) -> Void)?
@ObservationIgnored private var listener: PageListener?
@ObservationIgnored private var hosts: [PageSite: String] = [:]
@ObservationIgnored private var sites: [String: PageSite] = [:]
@ObservationIgnored private var views: [Page: WKWebView] = [:]
@ObservationIgnored private var sources: [Page: URL] = [:]
@ObservationIgnored private var titleWatches: [Page: NSKeyValueObservation] = [:]
@@ -157,25 +188,45 @@ final class Pages {
Task { @MainActor in
self?.port = port
}
}, served: { [weak self] taskId, path, body in
}, site: { @MainActor [weak self] host in
self?.sites[host]
}, served: { [weak self] site, path, body in
Task { @MainActor in
self?.served[taskId, default: [:]][path] = body
self?.served[site, default: [:]][path] = body
}
})
}
func url(_ taskId: String, _ path: String) -> URL? {
func url(_ root: FileRoot, _ path: String) -> URL? {
guard let port else {
return nil
}
var c = URLComponents()
c.scheme = "http"
c.host = "127.0.0.1"
c.host = host(PageSite(root, path))
c.port = port
c.path = "/\(taskId)/\(path)"
c.path = path.hasPrefix("/") ? path : "/\(path)"
return c.url
}
// WebKit lowercases the host it sends, so the listener finds the site by the lowercased name.
private func host(_ site: PageSite) -> String {
if let host = hosts[site] {
return host
}
let host = switch site {
case .root(.task(let id)), .root(.project(let id)): "\(id).localhost"
case .disk: "disk.localhost"
}
hosts[site] = host
sites[host.lowercased()] = site
return host
}
func closeSites(_ root: FileRoot) {
served[.root(root)] = nil
}
func existing(_ page: Page) -> WKWebView? {
views[page]
}
@@ -189,9 +240,9 @@ final class Pages {
return view
}
func web(_ taskId: String, _ url: URL) -> Page {
func web(_ root: FileRoot, _ url: URL) -> Page {
lastTab += 1
let page = Page.web(taskId: taskId, id: lastTab)
let page = Page.web(root, id: lastTab)
make(page, WKWebViewConfiguration()).load(URLRequest(url: url))
return page
}
@@ -201,7 +252,7 @@ final class Pages {
return nil
}
lastTab += 1
let page = Page.web(taskId: from.taskId, id: lastTab)
let page = Page.web(from.root, id: lastTab)
let view = make(page, config)
popup?(page)
return view
@@ -297,9 +348,14 @@ final class Pages {
port = nil
}
static func fetch(_ api: API, _ taskId: String, _ path: String) async -> (status: Int, type: String, body: Data) {
static func fetch(_ api: API, _ site: PageSite, _ path: String) async -> (status: Int, type: String, body: Data) {
let endpoint = switch site {
case .root(.task(let id)): "/api/tasks/\(id)/raw"
case .root(.project(let id)): "/api/projects/\(id)/raw"
case .disk: "/api/raw"
}
do {
let (body, response) = try await api.session.data(from: api.url("/api/tasks/\(taskId)/raw", [URLQueryItem(name: "path", value: path)]))
let (body, response) = try await api.session.data(from: api.url(endpoint, [URLQueryItem(name: "path", value: path)]))
let http = response as! HTTPURLResponse
return (http.statusCode, http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream", body)
} catch {
@@ -315,14 +371,16 @@ 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, String, Data) -> Void
private let site: @Sendable @MainActor (String) -> PageSite?
private let served: @Sendable (PageSite, 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, String, Data) -> Void) {
init(api: API, ready: @escaping @Sendable (Int) -> Void, site: @escaping @Sendable @MainActor (String) -> PageSite?, served: @escaping @Sendable (PageSite, String, Data) -> Void) {
self.api = api
self.ready = ready
self.site = site
self.served = served
queue.async {
self.listen()
@@ -429,9 +487,8 @@ private final class PageListener: @unchecked Sendable {
}
return line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
// A browser must not reach this authenticated file bridge through a
// hostname that an attacker can rebind to loopback.
guard port != 0, hosts == ["127.0.0.1:\(port)"] else {
let suffix = ".localhost:\(port)"
guard port != 0, hosts.count == 1, let host = hosts.first?.lowercased(), host.hasSuffix(suffix) else {
respond(conn, 403, "text/plain; charset=utf-8", Data("forbidden host".utf8))
return
}
@@ -440,17 +497,21 @@ private final class PageListener: @unchecked Sendable {
respond(conn, 400, "text/plain; charset=utf-8", Data("bad request".utf8))
return
}
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
let name = String(host.dropLast(":\(port)".count))
Task {
let (status, type, body) = await Pages.fetch(api, taskId, file)
self.served(taskId, file, body)
// Only *.localhost names the app handed out are served: the resolver answers those with loopback
// itself, so a site elsewhere can't rebind one onto this authenticated file bridge.
guard let site = await self.site(name) else {
self.respond(conn, 403, "text/plain; charset=utf-8", Data("forbidden host".utf8))
return
}
var file = site == .disk ? path : String(path.drop(while: { $0 == "/" }))
if file.isEmpty || file.hasSuffix("/") {
file += "index.html"
}
let (status, type, body) = await Pages.fetch(api, site, file)
self.served(site, file, body)
self.respond(conn, status, type, body)
}
}
@@ -469,19 +530,21 @@ private final class PageWatch {
private(set) var reloads = 0
@ObservationIgnored private var timer: Task<Void, Never>?
func check(_ api: API, _ pages: Pages, _ taskId: String) {
func check(_ api: API, _ pages: Pages, _ root: FileRoot) {
timer?.cancel()
timer = Task {
try? await Task.sleep(for: .milliseconds(1500))
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
for (site, files) in pages.served where site == .root(root) || site == .disk {
for (path, shown) in files {
let fresh = await Pages.fetch(api, site, path).body
if Task.isCancelled {
return
}
if fresh != shown {
pages.served[site]?[path] = fresh
changed = true
}
}
}
if changed {
@@ -525,14 +588,23 @@ struct PageTabs: View {
}
}
}
.task(id: shown.current.taskId) {
let taskId = shown.current.taskId
.task(id: shown.current.root) {
let root = shown.current.root
let watch = watch
let api = store.api
let pages = store.pages
let off = store.selectedFeed?.appended.add { events in
if events.contains(where: \.mayChangeWorktree) {
watch.check(api, pages, taskId)
let off: (() -> Void)? = switch root {
case .task:
store.selectedFeed?.appended.add { events in
if events.contains(where: \.mayChangeWorktree) {
watch.check(api, pages, root)
}
}
case .project(let id):
store.events.add { ev in
if ev.movesDefaultBranch && ev.projectId == id {
watch.check(api, pages, root)
}
}
}
await untilCancelled()
+2 -2
View File
@@ -224,7 +224,7 @@ struct TaskTabBody: View {
case .files:
FilesView(root: store.selectedTaskId.map(FileRoot.task))
case .page:
if let shown = store.selectedTaskId.flatMap({ store.shownPages[$0] }) {
if let shown = store.selectedTaskId.flatMap({ store.shownPages[.task($0)] }) {
PageTabs(shown: shown)
}
case .captures:
@@ -360,7 +360,7 @@ struct TabStrip: View {
}
switch t {
case .board: return team
case .page: return store.shownPages[taskId] != nil
case .page: return store.shownPages[.task(taskId)] != nil
case .captures: return !store.captures(taskId).isEmpty
case .workflow: return false
default: return true
+32 -10
View File
@@ -13,31 +13,53 @@ struct ProjectView: View {
@ViewState private var filterFocus = 0
var body: some View {
let shown = store.focusedProject().flatMap { store.shownPages[.project($0.id)] }
let tab = store.shownProjectTab()
VStack(spacing: 0) {
if store.focusedProject() != nil {
HStack(spacing: 4) {
PaneTabButton(label: "Tasks", active: overlays.projectTab == .tasks) {
overlays.projectTab = .tasks
PaneTabButton(label: "Tasks", active: tab == .tasks) {
store.projectTab = .tasks
}
PaneTabButton(label: "Files", active: overlays.projectTab == .files) {
overlays.projectTab = .files
PaneTabButton(label: "Files", active: tab == .files) {
store.projectTab = .files
}
PaneTabButton(label: "Settings", active: overlays.projectTab == .settings) {
overlays.projectTab = .settings
if shown != nil {
PaneTabButton(label: "Page", active: tab == .page) {
store.projectTab = .page
}
}
PaneTabButton(label: "Settings", active: tab == .settings) {
store.projectTab = .settings
}
Spacer(minLength: 0)
if tab == .page, let url = shown?.current.url(store) {
Button {
openExternal(url)
} label: {
Text("↗ Browser")
.font(Fonts.font(13))
.foregroundStyle(Palette.accent)
.padding(.horizontal, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Open the page in the default browser")
}
}
.padding(.horizontal, 12)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
if let p = store.focusedProject(), overlays.projectTab == .files {
if let p = store.focusedProject(), tab == .files {
FilesView(root: .project(p.id))
} else if let shown, tab == .page {
PageTabs(shown: shown)
} else {
ScrollView {
if let p = store.focusedProject() {
ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings, filter: $filter, filterFocus: filterFocus)
ProjectBlock(project: p, todo: todos[p.id], settings: tab == .settings, filter: $filter, filterFocus: filterFocus)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
@@ -78,7 +100,7 @@ struct ProjectView: View {
}
.onChange(of: overlays.filterTodo) {
if store.focusedProject() != nil {
overlays.projectTab = .tasks
store.projectTab = .tasks
filterFocus += 1
}
}
@@ -233,7 +255,7 @@ private func listedTasks(_ store: Store, _ overlays: Overlays) -> [ByreTask]? {
if overlays.taskList {
return store.projects.flatMap(active)
}
if store.selectedTaskId == nil, overlays.projectTab == .tasks, let p = store.focusedProject() {
if store.selectedTaskId == nil, store.shownProjectTab() == .tasks, let p = store.focusedProject() {
return active(p)
}
return nil
+2 -1
View File
@@ -83,7 +83,8 @@ final class Store {
var connected = false
private(set) var focus = Focus()
var sheet: Sheet?
var shownPages: [String: ShownPages] = [:]
var shownPages: [FileRoot: ShownPages] = [:]
var projectTab = ProjectTab.tasks
var leadSessions: [AgentSession] = []
let leadFeed: Feed
var jump: Jump?
+2 -2
View File
@@ -24,7 +24,7 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
overlays.switcher?.close()
store.sheet = nil
if let project = store.focusedProject() ?? store.projects.first {
overlays.projectTab = .tasks
store.projectTab = .tasks
overlays.takeListKeys(nil)
store.focusProject(project.id)
}
@@ -287,7 +287,7 @@ final class SwitcherModel {
go(task.id)
case .project(let p):
close()
overlays.projectTab = .tasks
store.projectTab = .tasks
overlays.takeListKeys(nil)
store.focusProject(p.id)
case .newTask(let p):