From dc2fcc607517db5c2e3dccd136fe11290c9dd082 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 12:17:17 -0700 Subject: [PATCH] Add a Save to Mac button for files in the Files tab --- api/openapi.yaml | 17 +++ internal/api/api.gen.go | 229 +++++++++++++++++++++-------------- internal/server/files.go | 19 ++- mac/Sources/Byre/Files.swift | 70 ++++++++++- 4 files changed, 240 insertions(+), 95 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a637215..bd8a580 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -520,6 +520,23 @@ paths: "400": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" } + /api/projects/{projectId}/raw: + parameters: + - { name: projectId, in: path, required: true, schema: { type: string } } + get: + operationId: getProjectRaw + description: > + A regular file on the server, 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, absolute or relative to the project's repository root." } + responses: + "200": + description: The file's bytes. + content: + "*/*": { schema: { type: string, format: binary } } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /api/projects/{projectId}/todo/tasks: parameters: - { name: projectId, in: path, required: true, schema: { type: string } } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index adbb6c9..4e40dcf 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1924,6 +1924,12 @@ type GetProjectImageParams struct { Path string `form:"path" json:"path"` } +// GetProjectRawParams defines parameters for GetProjectRaw. +type GetProjectRawParams struct { + // Path Slash-separated, absolute or relative to the project's repository root. + 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. @@ -2230,6 +2236,9 @@ type ServerInterface interface { // (GET /api/projects/{projectId}/image) GetProjectImage(w http.ResponseWriter, r *http.Request, projectId string, params GetProjectImageParams) + // (GET /api/projects/{projectId}/raw) + GetProjectRaw(w http.ResponseWriter, r *http.Request, projectId string, params GetProjectRawParams) + // (PUT /api/projects/{projectId}/review-workflow) SetReviewWorkflow(w http.ResponseWriter, r *http.Request, projectId string) @@ -3339,6 +3348,48 @@ func (siw *ServerInterfaceWrapper) GetProjectImage(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// GetProjectRaw operation middleware +func (siw *ServerInterfaceWrapper) GetProjectRaw(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "projectId" ------------- + var projectId string + + err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetProjectRawParams + + // ------------- 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.GetProjectRaw(w, r, projectId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // SetReviewWorkflow operation middleware func (siw *ServerInterfaceWrapper) SetReviewWorkflow(w http.ResponseWriter, r *http.Request) { @@ -5037,6 +5088,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/dir", wrapper.GetProjectDir) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/file", wrapper.GetProjectFile) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/image", wrapper.GetProjectImage) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/raw", wrapper.GetProjectRaw) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/todo/tasks", wrapper.StartTodoItem) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}", wrapper.GetTask) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/workflow", wrapper.GetTaskWorkflow) @@ -5385,94 +5437,95 @@ var swaggerSpec = []string{ "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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 b3353e7..950a76e 100644 --- a/internal/server/files.go +++ b/internal/server/files.go @@ -228,11 +228,24 @@ func (s *Server) GetTaskRaw(w http.ResponseWriter, r *http.Request, taskID strin if !ok { return } - if params.Path == "" { + serveRaw(w, r, t.WorktreePath, params.Path) +} + +func (s *Server) GetProjectRaw(w http.ResponseWriter, r *http.Request, projectID string, params api.GetProjectRawParams) { + p, err := s.store.GetProject(r.Context(), projectID) + if err != nil { + writeStoreError(w, err) + return + } + serveRaw(w, r, p.RepoPath, params.Path) +} + +func serveRaw(w http.ResponseWriter, r *http.Request, root, rel string) { + if rel == "" { writeError(w, http.StatusBadRequest, errors.New("path is required")) return } - name, full := taskPath(t.WorktreePath, params.Path) + name, full := taskPath(root, rel) f, err := os.Open(full) if err != nil { @@ -247,7 +260,7 @@ func (s *Server) GetTaskRaw(w http.ResponseWriter, r *http.Request, taskID strin return } if !fi.Mode().IsRegular() { - writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path)) + writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", rel)) return } diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index 6e2e75a..dd5b1b7 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -1,5 +1,6 @@ import ByreAPI import SwiftUI +import UniformTypeIdentifiers typealias DirListing = Components.Schemas.DirListing typealias DirEntry = Components.Schemas.DirEntry @@ -95,6 +96,8 @@ private final class FilesModel { private(set) var error = "" private(set) var generation = 0 private(set) var loads = 0 + var saving: FileCopy? + var saveError = "" @ObservationIgnored private var target = 0 @ObservationIgnored private var timer: Task? @@ -113,12 +116,21 @@ private final class FilesModel { store.filePlaces[root] = p } + func save(_ path: String) { + guard let root else { + return + } + saveError = "" + saving = FileCopy(api: store.api, root: root, path: path) + } + func reset() { target += 1 timer?.cancel() listing = nil content = nil error = "" + saveError = "" generation += 1 Task { await load() @@ -206,6 +218,12 @@ struct FilesView: View { if let model { FilesHead(model: model) FilesBody(model: model) + .fileExporter(isPresented: Binding { model.saving != nil } set: { if !$0 { model.saving = nil } }, item: model.saving, contentTypes: [.data], defaultFilename: model.saving.map { ($0.path as NSString).lastPathComponent }) { result in + model.saving = nil + if case .failure(let error) = result { + model.saveError = errorText(error) + } + } } } .frame(maxHeight: .infinity, alignment: .top) @@ -236,6 +254,37 @@ struct FilesView: View { } } +private struct FileCopy: Transferable, Sendable { + let api: API + let root: FileRoot + let path: String + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(exportedContentType: .data) { copy in + SentTransferredFile(try await copy.download()) + } + } + + private func download() async throws -> URL { + let body = switch root { + case .task(let id): try await api.client.getTaskRaw(path: .init(taskId: id), query: .init(path: path)).ok.body.any + case .project(let id): try await api.client.getProjectRaw(path: .init(projectId: id), query: .init(path: path)).ok.body.any + } + let dir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let dest = dir.appending(path: (path as NSString).lastPathComponent) + FileManager.default.createFile(atPath: dest.path, contents: nil) + let h = try FileHandle(forWritingTo: dest) + defer { + try? h.close() + } + for try await chunk in body { + try h.write(contentsOf: chunk) + } + return dest + } +} + private struct FilesHead: View { let model: FilesModel @@ -257,11 +306,18 @@ private struct FilesHead: View { } } Spacer(minLength: 0) - Text(model.summary) + Text(model.saveError.isEmpty ? model.summary : model.saveError) .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) + .foregroundStyle(model.saveError.isEmpty ? Palette.muted : Palette.danger) .lineLimit(1) .fixedSize() + if let file = p.file, model.content != nil { + Button("Save Copy…") { + model.save(file) + } + .buttonStyle(WebButtonStyle()) + .help("Save a copy of this file on this device") + } } .modifier(HeaderBar()) } @@ -307,13 +363,13 @@ private struct FilesBody: View { ScrollView { VStack(alignment: .leading, spacing: 0) { if !l.path.isEmpty && l.path != "/" { - EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false) { + EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false, save: nil) { model.go(FilePlace(dir: parent(l.path))) } } ForEach(l.entries, id: \.name) { e in let meta = [e.ignored ? "ignored" : nil, e._type == .file ? bytes(e.size) : nil].compactMap { $0 }.joined(separator: " · ") - EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink) { + 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 { @@ -349,6 +405,7 @@ private struct EntryRow: View { let ignored: Bool let meta: String let symlink: Bool + let save: (() -> Void)? let action: () -> Void @ViewState private var hover = false @@ -380,6 +437,11 @@ private struct EntryRow: View { } .buttonStyle(.plain) .onHover { hover = $0 } + .contextMenu { + if let save { + Button("Save Copy…", action: save) + } + } } } }