diff --git a/TODO.md b/TODO.md index ae097d5..ae63ce7 100644 --- a/TODO.md +++ b/TODO.md @@ -17,7 +17,5 @@ # M8 — macOS client -- Page view for HTML - Build once the attached sheet over the lead pane (mockup 3b, task "Left-side task context") has landed. A task view that shows an HTML page in a `WKWebView` inside that sheet, with the task's transcript staying on the right. It opens from an HTML attachment on a question, from clicking an `.html` file in Files, or from a preview link. It reloads in place when the agent changes the file, and has a "↗ Browser" button that opens the page at full width in the default browser. One-file pages load through the worktree file endpoint; a page's neighboring CSS and image files need serving too. Wireframe: `mockups/html-preview.html`, option 2. `docs/macos.md`, "Decision", allows this one web view. - Phone app A native client for the phone over the same API, sharing the macOS client's SwiftUI code where it can. Every surveyed tool has one (Claude, Codex, Cursor and Conductor apps, GitHub Mobile, Superset for iPhone); it needs its own layout, since two half-width panes don't fit a phone. diff --git a/api/openapi.yaml b/api/openapi.yaml index cf813c9..91319aa 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -798,6 +798,27 @@ paths: "404": { $ref: "#/components/responses/Error" } "409": { $ref: "#/components/responses/Error" } + /api/tasks/{taskId}/raw: + parameters: + - { name: taskId, in: path, required: true, schema: { type: string } } + get: + operationId: getTaskRaw + description: > + A regular file in the task's worktree, whole, with a content type chosen by its extension: an HTML page in the + app's Page view and the stylesheets, scripts and images it loads. The response carries a sandbox + Content-Security-Policy, so a page opened from this address directly can't act on the API as its viewer. + A path that leaves the worktree, through .. or a symlink, is refused. + parameters: + - { name: path, in: query, required: true, schema: { type: string }, description: "Slash-separated, relative to the worktree root." } + responses: + "200": + description: The file's bytes. + content: + "*/*": { schema: { type: string, format: binary } } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /api/terminals: get: operationId: listTerminals diff --git a/docs/macos.md b/docs/macos.md index bf616bf..a486d1b 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -35,6 +35,8 @@ Every build is signed for distribution outside the App Store: the team's Develop - The transcript is a plain `VStack` in a `ScrollView`, not lazy: a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. - The terminal cursor is a steady block: SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. SwiftTerm redraws the whole screen whenever a terminal color is set, even when its value hasn't changed. +- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port (`http://127.0.0.1://`), 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, and "↗ Browser" opens the same address. Previews load from their loopback forwards at `-.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). + ## Updates The update UI must not offer Skip or turning automatic updates off: on a Mac running the server, `byre-update` would override either choice while the app isn't running. diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index d499fdc..ecc580b 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1892,6 +1892,12 @@ type MergeTaskJSONBody struct { // MergeTaskJSONBodyTarget defines parameters for MergeTask. type MergeTaskJSONBodyTarget string +// GetTaskRawParams defines parameters for GetTaskRaw. +type GetTaskRawParams struct { + // Path Slash-separated, relative to the worktree root. + Path string `form:"path" json:"path"` +} + // GetTaskTerminalParams defines parameters for GetTaskTerminal. type GetTaskTerminalParams struct { Cols int `form:"cols" json:"cols"` @@ -2200,6 +2206,9 @@ type ServerInterface interface { // (DELETE /api/tasks/{taskId}/queue/{inputId}) WithdrawMessage(w http.ResponseWriter, r *http.Request, taskId string, inputId int64) + // (GET /api/tasks/{taskId}/raw) + GetTaskRaw(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskRawParams) + // (DELETE /api/tasks/{taskId}/ready) DismissReady(w http.ResponseWriter, r *http.Request, taskId string) @@ -4004,6 +4013,48 @@ func (siw *ServerInterfaceWrapper) WithdrawMessage(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// GetTaskRaw operation middleware +func (siw *ServerInterfaceWrapper) GetTaskRaw(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "taskId" ------------- + var taskId string + + err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTaskRawParams + + // ------------- Required query parameter "path" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "path", r.URL.Query(), ¶ms.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.GetTaskRaw(w, r, taskId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DismissReady operation middleware func (siw *ServerInterfaceWrapper) DismissReady(w http.ResponseWriter, r *http.Request) { @@ -4661,6 +4712,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/dir", wrapper.GetTaskDir) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/file", wrapper.GetTaskFile) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/raw", wrapper.GetTaskRaw) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/terminals", wrapper.ListTerminals) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/terminals", wrapper.StartTaskTerminal) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/tasks/{taskId}/terminals/{terminalId}", wrapper.CloseTaskTerminal) @@ -4959,95 +5011,96 @@ var swaggerSpec = []string{ "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", - "5Qp9jEjchIkoYGjhgJn8ZpTUHz/+rwEA", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/files.go b/internal/server/files.go index 7c079fe..e690ed7 100644 --- a/internal/server/files.go +++ b/internal/server/files.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "io/fs" + "mime" "net/http" "os" "path" @@ -215,6 +216,55 @@ func (s *Server) GetTaskFile(w http.ResponseWriter, r *http.Request, taskID stri writeJSON(w, http.StatusOK, fc) } +func (s *Server) GetTaskRaw(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskRawParams) { + t, ok := s.activeTask(w, r, taskID) + if !ok { + return + } + if params.Path == "" { + writeError(w, http.StatusBadRequest, errors.New("path is required")) + return + } + name, ok := worktreeName(w, params.Path) + if !ok { + return + } + + root, err := os.OpenRoot(t.WorktreePath) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + defer root.Close() + + f, err := root.Open(name) + if err != nil { + writePathError(w, err) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if !fi.Mode().IsRegular() { + writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path)) + return + } + + contentType := mime.TypeByExtension(path.Ext(name)) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "sandbox") + w.Header().Set("Cache-Control", "private, no-cache") + http.ServeContent(w, r, "", fi.ModTime(), f) +} + func worktreeName(w http.ResponseWriter, rel string) (string, bool) { if rel == "" { return ".", true diff --git a/mac/Info.plist b/mac/Info.plist index ca9df1b..bbba5e3 100644 --- a/mac/Info.plist +++ b/mac/Info.plist @@ -24,6 +24,19 @@ SUAutomaticallyUpdate + NSAppTransportSecurity + + NSExceptionDomains + + localhost + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + + + NSAppleEventsUsageDescription byre's agents drive Orion on this Mac through AppleScript when a task asks them to. diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index d4f7e30..4b2d466 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -1676,7 +1676,7 @@ struct NoteView: View { continue } var a = AttributedString(url.absoluteString) - a.link = url + a.link = AppLink.preview(taskId, port) a.foregroundColor = Palette.accent out += a } diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index 3283da8..7758f1a 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -21,6 +21,11 @@ extension Store { } func openFile(_ taskId: String, _ path: String) { + if isPage(path) { + route("#/task/\(taskId)") + openPage(.file(taskId: taskId, path: path)) + return + } filePlaces[taskId] = path.hasSuffix("/") ? FilePlace(dir: String(path.dropLast())) : FilePlace(dir: parent(path), file: path) route("#/task/\(taskId)/files") } @@ -266,6 +271,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) { if e._type == .dir { model.go(FilePlace(dir: join(l.path, e.name))) + } else if e._type == .file && isPage(e.name), let taskId = store.selectedTaskId { + store.openPage(.file(taskId: taskId, path: join(l.path, e.name))) } else if e._type == .file { model.go(FilePlace(dir: l.path, file: join(l.path, e.name))) } diff --git a/mac/Sources/Byre/Forwards.swift b/mac/Sources/Byre/Forwards.swift index e1f97b9..9dfc4db 100644 --- a/mac/Sources/Byre/Forwards.swift +++ b/mac/Sources/Byre/Forwards.swift @@ -34,6 +34,10 @@ final class Forwards { return c.url } + func name(_ taskId: String, _ port: Int) -> String? { + localPorts.keys.first { $0.taskId == taskId && $0.port == port }?.name + } + func load(_ api: API, _ taskId: String) async { do { set(taskId, try await api.client.listPreviews(path: .init(taskId: taskId)).ok.body.json) diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 4830c6a..fc7da13 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -26,6 +26,14 @@ enum AppLink { return c.url! } + static func preview(_ taskId: String, _ port: Int) -> URL { + var c = URLComponents() + c.scheme = "byre-app" + c.host = "preview" + c.queryItems = [URLQueryItem(name: "task", value: taskId), URLQueryItem(name: "port", value: String(port))] + return c.url! + } + static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL { var c = URLComponents() c.scheme = "byre-app" @@ -54,6 +62,10 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct store.route(q["hash"] ?? "") case "file": store.openFile(q["task"] ?? "", q["path"] ?? "") + case "preview": + let taskId = q["task"] ?? "" + store.route("#/task/\(taskId)") + store.openPage(.preview(taskId: taskId, port: Int(q["port"] ?? "") ?? 0)) case "todo": store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays) case "suggest": diff --git a/mac/Sources/Byre/Pages.swift b/mac/Sources/Byre/Pages.swift new file mode 100644 index 0000000..457b354 --- /dev/null +++ b/mac/Sources/Byre/Pages.swift @@ -0,0 +1,301 @@ +import AppKit +import Foundation +import Network +import Observation +import SwiftUI +import WebKit + +enum Page: Equatable { + case file(taskId: String, path: String) + case preview(taskId: String, port: Int) + + var taskId: String { + switch self { + case .file(let taskId, _), .preview(let taskId, _): taskId + } + } + + @MainActor + func title(_ store: Store) -> String { + switch self { + case .file(_, let path): (path as NSString).lastPathComponent + case .preview(let taskId, let port): store.forwards.name(taskId, port) ?? "port \(port)" + } + } + + @MainActor + func url(_ store: Store) -> URL? { + switch self { + case .file(let taskId, let path): store.pages.url(taskId, path) + case .preview(let taskId, let port): store.forwards.url(taskId, port) + } + } +} + +private let pageExts: Set = ["html", "htm"] + +func isPage(_ path: String) -> Bool { + pageExts.contains((path as NSString).pathExtension.lowercased()) +} + +extension Store { + func openPage(_ page: Page) { + sheet = Sheet(owner: .task(page.taskId), view: .page(page)) + } +} + +@MainActor +@Observable +final class Pages { + private(set) var port: Int? + @ObservationIgnored var served: [String: [String: Data]] = [:] + @ObservationIgnored private var listener: PageListener? + + init(api: API) { + listener = PageListener(api: api, ready: { [weak self] port in + Task { @MainActor in + self?.port = port + } + }, served: { [weak self] taskId, path, body in + Task { @MainActor in + self?.served[taskId, default: [:]][path] = body + } + }) + } + + func url(_ taskId: String, _ path: String) -> URL? { + guard let port else { + return nil + } + var c = URLComponents() + c.scheme = "http" + c.host = "127.0.0.1" + c.port = port + c.path = "/\(taskId)/\(path)" + return c.url + } + + func close() { + listener?.close() + listener = nil + port = nil + } + + 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/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 { + return (502, "text/plain; charset=utf-8", Data(errorText(error).utf8)) + } + } +} + +private let headEnd = Data("\r\n\r\n".utf8) +private let maxHead = 64 << 10 + +private final class PageListener: @unchecked Sendable { + private let api: API + private let queue = DispatchQueue(label: "run.fc.byre.pages") + private let served: @Sendable (String, String, Data) -> Void + private var listener: NWListener? + + init(api: API, ready: @escaping @Sendable (Int) -> Void, served: @escaping @Sendable (String, String, Data) -> Void) { + self.api = api + self.served = served + let params = NWParameters.tcp + params.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) + let listener: NWListener + do { + listener = try NWListener(using: params) + } catch { + log.error("page listener: \(String(describing: error), privacy: .public)") + return + } + self.listener = listener + listener.stateUpdateHandler = { [weak listener] state in + switch state { + case .ready: + if let port = listener?.port { + ready(Int(port.rawValue)) + } + case .failed(let error): + log.error("page listener: \(String(describing: error), privacy: .public)") + default: + break + } + } + listener.newConnectionHandler = { [weak self] conn in + guard let self else { + conn.cancel() + return + } + conn.start(queue: self.queue) + self.read(conn, Data()) + } + listener.start(queue: queue) + } + + func close() { + queue.async { + self.listener?.newConnectionHandler = nil + self.listener?.cancel() + self.listener = nil + } + } + + private func read(_ conn: NWConnection, _ buf: Data) { + conn.receive(minimumIncompleteLength: 1, maximumLength: maxHead) { data, _, complete, error in + var buf = buf + if let data { + buf.append(data) + } + if let end = buf.range(of: headEnd) { + self.serve(conn, String(decoding: buf[.. maxHead { + conn.cancel() + return + } + self.read(conn, buf) + } + } + + private func serve(_ conn: NWConnection, _ head: String) { + let parts = head.components(separatedBy: "\r\n")[0].split(separator: " ") + guard parts.count == 3, parts[0] == "GET", let path = URLComponents(string: String(parts[1]))?.path else { + 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 + Task { + let (status, type, body) = await Pages.fetch(api, taskId, file) + self.served(taskId, file, body) + self.respond(conn, status, type, body) + } + } + + private func respond(_ conn: NWConnection, _ status: Int, _ type: String, _ body: Data) { + let head = "HTTP/1.1 \(status) \(HTTPURLResponse.localizedString(forStatusCode: status))\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n" + conn.send(content: Data(head.utf8) + body, isComplete: true, completion: .contentProcessed { _ in + conn.cancel() + }) + } +} + +@MainActor +@Observable +private final class PageWatch { + private(set) var reloads = 0 + @ObservationIgnored private var timer: Task? + + func check(_ api: API, _ pages: Pages, _ taskId: String) { + 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 + } + } + if changed { + reloads += 1 + } + } + } + + func stop() { + timer?.cancel() + timer = nil + } +} + +struct PageView: View { + let page: Page + @Environment(Store.self) private var store + @ViewState private var watch = PageWatch() + + var body: some View { + Group { + if let url = page.url(store) { + WebPage(url: url, reloads: watch.reloads) + } else { + EmptyText(text: "Loading…") + } + } + .task(id: page) { + guard case .file(let taskId, _) = page else { + return + } + let watch = watch + 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" }) { + watch.check(api, pages, taskId) + } + } + await untilCancelled() + off?() + watch.stop() + } + } +} + +private struct WebPage: NSViewRepresentable { + let url: URL + let reloads: Int + + final class Coordinator: NSObject, WKUIDelegate { + var url: URL? + var reloads = 0 + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + if let url = action.request.url { + NSWorkspace.shared.open(url) + } + return nil + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + let view = WKWebView(frame: .zero, configuration: config) + view.uiDelegate = context.coordinator + return view + } + + func updateNSView(_ view: WKWebView, context: Context) { + let c = context.coordinator + if c.url != url { + c.url = url + c.reloads = reloads + view.load(URLRequest(url: url)) + return + } + if c.reloads != reloads { + c.reloads = reloads + view.reload() + } + } +} diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 1c3f045..e37a8d9 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -277,8 +277,8 @@ private struct TaskBar: View { HStack(spacing: 12) { Spacer(minLength: 0) ForEach(previews, id: \.port) { p in - if let url = store.forwards.url(p.taskId, p.port) { - PreviewLink(preview: p, url: url) + if store.forwards.url(p.taskId, p.port) != nil { + PreviewLink(preview: p) } } } @@ -336,18 +336,21 @@ private struct TaskBar: View { private struct PreviewLink: View { let preview: Components.Schemas.Preview - let url: URL + @Environment(Store.self) private var store @Environment(\.paneAccent) private var accent @ViewState private var hover = false var body: some View { - Link(destination: url) { - Text("\(preview.name) ↗") + Button { + store.openPage(.preview(taskId: preview.taskId, port: preview.port)) + } label: { + Text(preview.name) .foregroundStyle(accent) .underline(hover) .lineLimit(1) } - .help("Open \(preview.name) (port \(preview.port)) in a new window") + .buttonStyle(.plain) + .help("Show \(preview.name) (port \(preview.port)) beside the transcript") .onHover { hover = $0 } } } diff --git a/mac/Sources/Byre/Questions.swift b/mac/Sources/Byre/Questions.swift index 78e5bab..4310e8a 100644 --- a/mac/Sources/Byre/Questions.swift +++ b/mac/Sources/Byre/Questions.swift @@ -506,8 +506,20 @@ private struct Attachments: View { private func content(_ a: Attachment) -> some View { switch a.kind { case .file: - let lines = a.startLine.map { ":\($0)\(a.endLine.map { "-\($0)" } ?? "")" } ?? "" - code("\(a.path ?? "")\(lines)").textSelection(.enabled) + let path = a.path ?? "" + let root = (store.findTask(taskId)?.worktreePath ?? "") + "/" + let rel = path.hasPrefix(root) ? String(path.dropFirst(root.count)) : path + if isPage(rel) && !rel.hasPrefix("/") { + Button(path) { + store.openFile(taskId, rel) + } + .buttonStyle(.plain) + .foregroundStyle(Palette.accent) + .help("Show the page beside the transcript") + } else { + let lines = a.startLine.map { ":\($0)\(a.endLine.map { "-\($0)" } ?? "")" } ?? "" + code("\(path)\(lines)").textSelection(.enabled) + } case .diff: Text("diff \(code(a.ref ?? ""))").textSelection(.enabled) case .image: diff --git a/mac/Sources/Byre/Sheet.swift b/mac/Sources/Byre/Sheet.swift index 295fa2d..1102790 100644 --- a/mac/Sources/Byre/Sheet.swift +++ b/mac/Sources/Byre/Sheet.swift @@ -5,6 +5,7 @@ enum SheetView { case tab(TaskTab) case image(NSImage) case diagram(BoxLayout) + case page(Page) } struct Sheet { @@ -81,6 +82,19 @@ private struct SheetPanel: View { .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 0) + if case .page(let page) = sheet.view, let url = page.url(store) { + Button { + NSWorkspace.shared.open(url) + } label: { + Text("↗ Browser") + .font(Fonts.font(13, weight: 700)) + .foregroundStyle(accent) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open the page in the default browser") + } Button { store.sheet = nil } label: { @@ -115,6 +129,7 @@ private struct SheetPanel: View { case .tab(let t): tabLabels[t]! case .image: "Image" case .diagram: "Diagram" + case .page(let page): page.title(store) } if !overLeft { return "Lead · \(label)" @@ -144,6 +159,8 @@ private struct SheetPanel: View { DiagramViewer(layout: layout) .id(layout.id) .padding(12) + case .page(let page): + PageView(page: page) } } } diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 3e379d8..48735ab 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -172,6 +172,7 @@ final class Connection { let origin: URL let overlays = Overlays() let forwards: Forwards + let pages: Pages var phase: Phase = .starting @ObservationIgnored private var stopped = false @ObservationIgnored private var restarting = false @@ -179,6 +180,7 @@ final class Connection { init(origin: URL) { self.origin = origin forwards = Forwards(host: origin.host()!) + pages = Pages(api: API(origin: origin)) Task { await start() } @@ -194,6 +196,7 @@ final class Connection { func stop() async { stopped = true forwards.closeAll() + pages.close() if let store { await store.stop() } @@ -228,7 +231,7 @@ final class Connection { } private func attempt(_ old: Store?) async -> Bool { - let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards) + let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards, pages: pages) do { let status = try await store.api.client.getStatus().ok.body.json async let me: Void = store.loadMe() diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 4d36442..1b0481b 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -88,6 +88,7 @@ final class Store { let paneFocus: PaneFocus let remote: Remote let forwards: Forwards + let pages: Pages private var taskFeeds: [String: Feed] = [:] @ObservationIgnored private var historyLoads: Set = [] @@ -102,10 +103,11 @@ final class Store { let changesMemory = ChangesMemory() @ObservationIgnored private var localSends: [String: LocalSends] = [:] - init(api: API, images: ImageDrafts, forwards: Forwards) { + init(api: API, images: ImageDrafts, forwards: Forwards, pages: Pages) { self.api = api self.images = images self.forwards = forwards + self.pages = pages leadFeed = Feed(shown: { true }) paneFocus = PaneFocus(api: api) remote = Remote(api: api)