Add a Save to Mac button for files in the Files tab

This commit is contained in:
Ian Gulliver
2026-10-23 12:17:17 -07:00
parent 1383bd6ae5
commit dc2fcc6075
4 changed files with 240 additions and 95 deletions
+66 -4
View File
@@ -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<Void, Never>?
@@ -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)
}
}
}
}
}