aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/Files/FilesView.swift
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS/Files/FilesView.swift
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/Files/FilesView.swift')
-rw-r--r--Hotline/macOS/Files/FilesView.swift529
1 files changed, 529 insertions, 0 deletions
diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift
new file mode 100644
index 0000000..c9c4c24
--- /dev/null
+++ b/Hotline/macOS/Files/FilesView.swift
@@ -0,0 +1,529 @@
+import SwiftUI
+import UniformTypeIdentifiers
+import AppKit
+
+struct FilesView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @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
+ @State private var dragOver: Bool = false
+ @State private var confirmDeleteShown: Bool = false
+ @State private var newFolderShown: Bool = false
+
+ var body: some View {
+ NavigationStack {
+ List(self.displayedFiles, id: \.self, selection: self.$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)
+ .onDrop(of: [.fileURL], isTargeted: self.$dragOver) { items in
+ guard self.model.access?.contains(.canUploadFiles) == true,
+ 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) {
+
+ // Access security-scoped resource for drag-and-drop
+ let didStartAccessing = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccessing {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ self.upload(file: fileURL, to: [])
+ }
+ }
+ }
+
+ return true
+ }
+ .task {
+ if !self.model.filesLoaded {
+ let _ = try? await self.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 {
+ self.confirmDeleteShown = true
+ } 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 {
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection, selectedFile.isPreviewable {
+ self.previewFile(selectedFile)
+ }
+ } label: {
+ Label("Preview", systemImage: "eye")
+ }
+ .help("Preview")
+ .disabled(selection == nil || selection?.isPreviewable != true)
+ }
+
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection {
+ self.getFileInfo(selectedFile)
+ }
+ } label: {
+ Label("Get Info", systemImage: "info.circle")
+ }
+ .help("Get Info")
+ .disabled(selection == nil)
+ }
+
+ ToolbarItem {
+ Button {
+ self.uploadFileSelectorDisplayed = true
+ } label: {
+ Label("Upload", systemImage: "arrow.up")
+ }
+ .help("Upload")
+ .disabled(model.access?.contains(.canUploadFiles) != true)
+ }
+
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection {
+ self.downloadFile(selectedFile)
+ }
+ } label: {
+ Label("Download", systemImage: "arrow.down")
+ }
+ .help("Download")
+ .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true)
+ }
+
+ if #available(macOS 26.0, *) {
+ ToolbarSpacer()
+ }
+
+ if self.model.access?.contains(.canCreateFolders) == true {
+ ToolbarItem {
+ Button {
+ self.newFolderShown = true
+ } label: {
+ Label("New Folder", systemImage: "folder.badge.plus")
+ }
+ .help("New Folder")
+ .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) {
+ NewFolderPopover { folderName in
+ self.newFolder(name: folderName, parent: self.selection)
+ }
+ }
+ }
+ }
+
+ if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true {
+ ToolbarItem {
+ Button {
+ self.confirmDeleteShown = true
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .disabled(self.selection == nil)
+ .help("Delete")
+ }
+ }
+ }
+ }
+ .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: {
+ Button("Delete", role: .destructive) {
+ if let s = self.selection {
+ Task {
+ await self.deleteFile(s)
+ }
+ }
+ }
+ }, message: {
+ Text("You cannot undo this action.")
+ })
+ .sheet(item: self.$fileDetails) { item in
+ FileDetailsSheet(details: item)
+ }
+ .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in
+ switch results {
+ case .success(let fileURLS):
+ guard fileURLS.count > 0,
+ let fileURL = fileURLS.first
+ else {
+ return
+ }
+
+ 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)")
+ self.upload(file: fileURL, to: 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)
+ }
+ }
+ }
+
+ 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:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ case .text:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ case .unknown:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ return
+ }
+ }
+
+ @MainActor private func newFolder(name: String, parent: FileInfo?) {
+ Task {
+ var parentFolder: FileInfo? = nil
+ if parent?.isFolder == true {
+ parentFolder = parent
+ }
+
+ let path: [String] = parentFolder?.path ?? []
+ if try await self.model.newFolder(name: name, parentPath: path) {
+ try await self.model.getFileList(path: path)
+ }
+ }
+ }
+
+ @MainActor private func getFileInfo(_ file: FileInfo) {
+ Task {
+ if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) {
+ self.fileDetails = fileInfo
+ }
+ }
+ }
+
+ @MainActor private func downloadFile(_ file: FileInfo) {
+ if file.isFolder {
+ self.model.downloadFolder(file.name, path: file.path)
+ }
+ else {
+ self.model.downloadFile(file.name, path: file.path)
+ }
+ }
+
+ @MainActor private func uploadFile(file fileURL: URL, to path: [String]) throws {
+ self.model.uploadFile(url: fileURL, path: path) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await self.model.getFileList(path: path)
+ }
+ }
+ }
+
+ @MainActor private func upload(file fileURL: URL, to path: [String]) {
+ var fileIsDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else {
+ return
+ }
+
+ if fileIsDirectory.boolValue {
+ self.model.uploadFolder(url: fileURL, path: path, complete: { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: path)
+ }
+ })
+ }
+ else {
+ self.model.uploadFile(url: fileURL, path: path) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: path)
+ }
+ }
+ }
+ }
+
+ @MainActor private func previewFile(_ file: FileInfo) {
+ guard file.isPreviewable else {
+ return
+ }
+
+ self.model.previewFile(file.name, path: file.path) { info in
+ if let info = info {
+ var extendedInfo = info
+ extendedInfo.creator = file.creator
+ extendedInfo.type = file.type
+ self.openPreviewWindow(extendedInfo)
+ }
+ }
+ }
+
+ private func deleteFile(_ file: FileInfo) async {
+ var parentPath: [String] = []
+ if file.path.count > 1 {
+ parentPath = Array(file.path[0..<file.path.count-1])
+ }
+
+ do {
+ try await self.model.deleteFile(file.name, path: file.path)
+ try await self.model.getFileList(path: parentPath)
+ }
+ catch {
+ print("Error deleting file: \(error)")
+ }
+ }
+}
+
+#Preview {
+ FilesView()
+ .environment(HotlineState())
+}