diff options
Diffstat (limited to 'Hotline/macOS/Files')
| -rw-r--r-- | Hotline/macOS/Files/FileDetailsView.swift | 147 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FileItemView.swift | 67 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilePreviewImageView.swift | 123 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilePreviewTextView.swift | 127 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilesView.swift | 418 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FolderItemView.swift | 140 |
6 files changed, 1022 insertions, 0 deletions
diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift new file mode 100644 index 0000000..812f4e5 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -0,0 +1,147 @@ +import Foundation +import SwiftUI + +struct FileDetailsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack (alignment: .leading){ + Form { + HStack(alignment: .center){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + HStack(alignment: .center){ + Text("Type:").bold().padding(.leading, 43) + Text(fd.type) + } + HStack(alignment: .center){ + Text("Creator:").bold().padding(.leading, 26) + Text(fd.creator) + } + HStack(alignment: .center){ + Text("Size:").bold().bold().padding(.leading, 48) + Text(self.formattedSize(byteCount: fd.size)) + } + HStack(alignment: .center){ + Text("Created:").bold().padding(.leading, 24) + Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") + } + HStack(alignment: .center){ + Text("Modified:").bold().padding(.leading, 19) + Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") + } + HStack(alignment: .center){ + Text("Comments:").bold().padding(.top, 8) + .padding(.leading, 5) + } + + VStack(alignment: .trailing){ + TextEditor(text: $comment) + .padding(.leading, 2) + .padding(.top, 1) + .font(.system(size: 13)) + .background(Color(nsColor: .textBackgroundColor)) + .border(Color.secondary, width: 1) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .disabled(!self.canSetComment()) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + presentationMode.wrappedValue.dismiss() + + // TODO: Update the file list if the filename was changed + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + } + .frame(minWidth: 400, minHeight: 400) + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return model.access?.contains(.canSetFileComment) == true + } +} + +//#Preview { +// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +//} diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift new file mode 100644 index 0000000..5da744f --- /dev/null +++ b/Hotline/macOS/Files/FileItemView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct FileItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var file: FileInfo + let depth: Int + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else { + FileIconView(filename: file.name, fileType: file.type) + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + Spacer() + if !file.isUnavailable { + Text(formattedFileSize(file.fileSize)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } +} diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift new file mode 100644 index 0000000..9beeb80 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -0,0 +1,123 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewImageView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + HStack(alignment: .center, spacing: 0) { + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + else { + if let image = preview?.image { + FileImageView(image: image) + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .focused($focusField, equals: .window) + .preferredColorScheme(.dark) + .navigationTitle(info?.name ?? "Preview") + .background(.black) + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let img = preview?.image { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Download Image...", systemImage: "arrow.down") + } + .help("Download Image") + } + + ToolbarItem(placement: .primaryAction) { + ShareLink(item: img, preview: SharePreview(info.name, image: img)) { + Label("Share Image...", systemImage: "square.and.arrow.up") + } + .help("Share Image") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift new file mode 100644 index 0000000..c286381 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewTextView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + Spacer() + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let text = preview?.text { + TextEditor(text: .constant(text)) + .textEditorStyle(.plain) + .font(.system(size: 14)) + .lineSpacing(3) + .padding(16) + .contentMargins(.top, -16.0, for: .scrollIndicators) + .contentMargins(.bottom, -16.0, for: .scrollIndicators) + .contentMargins(.trailing, -16.0, for: .scrollIndicators) + .scrollClipDisabled() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let _ = preview?.text { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Save Text File...", systemImage: "square.and.arrow.down") + } + .help("Save Text File") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift new file mode 100644 index 0000000..de699ff --- /dev/null +++ b/Hotline/macOS/Files/FilesView.swift @@ -0,0 +1,418 @@ +import SwiftUI +import UniformTypeIdentifiers +import AppKit + + + + + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + + @State private var selection: FileInfo? + @State private var fileDetails: FileDetails? + @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + @State private var isSearching: Bool = false + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, _): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(_): + if model.fileSearchResults.isEmpty { + return nil + } + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return path.joined(separator: "/") + } + + private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { + switch previewInfo.previewType { + case .image: + openWindow(id: "preview-image", value: previewInfo) + case .text: + openWindow(id: "preview-text", value: previewInfo) + default: + return + } + } + + @MainActor private func getFileInfo(_ file: FileInfo) { + Task { + if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + Task { @MainActor in + self.fileDetails = fileInfo + } + } + } + } + + @MainActor private func downloadFile(_ file: FileInfo) { + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) + } + } + + @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { + model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: path) + } + } + } + + @MainActor private func previewFile(_ file: FileInfo) { + guard file.isPreviewable else { + return + } + + model.previewFile(file.name, path: file.path) { info in + if let info = info { + openPreviewWindow(info) + } + } + } + + private func deleteFile(_ file: FileInfo) async { + var parentPath: [String] = [] + if file.path.count > 1 { + parentPath = Array(file.path[0..<file.path.count-1]) + } + + if await model.deleteFile(file.name, path: file.path) { + let _ = await model.getFileList(path: parentPath) + } + } + + var body: some View { + NavigationStack { + List(displayedFiles, id: \.self, selection: $selection) { file in + if file.isFolder { + FolderItemView(file: file, depth: 0).tag(file.id) + } + else { + FileItemView(file: file, depth: 0).tag(file.id) + } + } + .environment(\.defaultMinListRowHeight, 28) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if !model.filesLoaded { + let _ = await model.getFileList() + } + } + .contextMenu(forSelectionType: FileInfo.self) { items in + let selectedFile = items.first + + Button { + if let s = selectedFile { + downloadFile(s) + } + } label: { + Label("Download", systemImage: "arrow.down") + } + .disabled(selectedFile == nil) + + Divider() + + Button { + if let s = selectedFile { + getFileInfo(s) + } + } label: { + Label("Get Info", systemImage: "info.circle") + } + .disabled(selectedFile == nil) + + Button { + if let s = selectedFile { + previewFile(s) + } + } label: { + Label("Preview", systemImage: "eye") + } + .disabled(selectedFile == nil || (selectedFile != nil && !selectedFile!.isPreviewable)) + + if model.access?.contains(.canDeleteFiles) == true { + Divider() + + Button { + if let s = selectedFile { + Task { + await deleteFile(s) + } + } + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(selectedFile == nil) + } + } primaryAction: { items in + guard let clickedFile = items.first else { + return + } + + self.selection = clickedFile + if clickedFile.isFolder { + clickedFile.expanded.toggle() + } + else { + downloadFile(clickedFile) + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.isFolder { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.isFolder { + s.expanded = false + return .handled + } + return .ignored + } + .onKeyPress(.space) { + if let s = selection, s.isPreviewable { + previewFile(s) + return .handled + } + return .ignored + } + .overlay { + if !model.filesLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + .toolbar { + ToolbarItemGroup(placement: .automatic) { + Button { + if let selectedFile = selection, selectedFile.isPreviewable { + previewFile(selectedFile) + } + } label: { + Label("Preview", systemImage: "eye") + } + .help("Preview") + .disabled(selection == nil || selection?.isPreviewable == false) + + Button { + if let selectedFile = selection { + getFileInfo(selectedFile) + } + } label: { + Label("Get Info", systemImage: "info.circle") + } + .help("Get Info") + .disabled(selection == nil) + + Button { + uploadFileSelectorDisplayed = true + } label: { + Label("Upload", systemImage: "arrow.up") + } + .help("Upload") + .disabled( + model.access?.contains(.canUploadFiles) != true || + (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false)) + ) + + Button { + if let selectedFile = selection { + downloadFile(selectedFile) + } + } label: { + Label("Download", systemImage: "arrow.down") + } + .help("Download") + .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) + } + } + } + .sheet(item: $fileDetails ) { item in + FileDetailsView(fd: item) + } + .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data], allowsMultipleSelection: false, onCompletion: { results in + switch results { + case .success(let fileURLS): + guard fileURLS.count > 0 else { + return + } + + let fileURL = fileURLS.first! + + print(fileURL) + + var uploadPath: [String] = [] + + if let selection = selection { + if selection.isFolder { + uploadPath = selection.path + } + else { + uploadPath = Array<String>(selection.path) + uploadPath.removeLast() + } + } + + print("UPLOAD PATH: \(uploadPath)") + uploadFile(file: fileURL, to: uploadPath) + + case .failure(let error): + print(error) + } + }) + .onSubmit(of: .search) { + #if os(macOS) + let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shiftPressed { + model.clearFileListCache() + } + #endif + + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack(alignment: .center, spacing: 6) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .accentColor(.white) + .tint(.white) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .lineLimit(1) + .font(.body) + .foregroundStyle(.white) + + Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } + } + .padding(.trailing, 14) + .padding(.leading, 8) + .padding(.vertical, 8) + .background { + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift new file mode 100644 index 0000000..4a08974 --- /dev/null +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FolderItemView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var loading = false + @State var dragOver = false + + var file: FileInfo + let depth: Int + + @MainActor private func uploadFile(file fileURL: URL) { + var filePath: [String] = [String](self.file.path) + if !self.file.isFolder { + filePath.removeLast() + } + + print("UPLOADING TO PATH: ", filePath) + + model.uploadFile(url: fileURL, path: filePath) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: filePath) + } + } + } + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Button { + if file.isFolder { + file.expanded.toggle() + } + } label: { + Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else if file.isAdminDropboxFolder { + Image("Admin Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else if file.isDropboxFolder { + Image("Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + if loading { + ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + } + Spacer() + if !file.isUnavailable { + Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") + .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 4.0) + .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, -6) + .padding(.vertical, -4) + ) + .onChange(of: file.expanded) { + loading = false + if file.expanded && file.fileSize > 0 { + Task { + loading = true + let _ = await model.getFileList(path: file.path) + loading = false + } + } + } + .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in + guard let item = items.first, + let identifier = item.registeredTypeIdentifiers.first else { + return false + } + + item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in + DispatchQueue.main.async { + if let urlData = urlData as? Data, + let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { + uploadFile(file: fileURL) + } + } + } + + return true + } + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } +} |