aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/Files
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline/macOS/Files')
-rw-r--r--Hotline/macOS/Files/FileDetailsView.swift147
-rw-r--r--Hotline/macOS/Files/FileItemView.swift67
-rw-r--r--Hotline/macOS/Files/FilePreviewImageView.swift123
-rw-r--r--Hotline/macOS/Files/FilePreviewTextView.swift127
-rw-r--r--Hotline/macOS/Files/FilesView.swift418
-rw-r--r--Hotline/macOS/Files/FolderItemView.swift140
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)
+ }
+ }
+ }
+ }
+}