diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-11 17:14:13 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-11 17:14:13 -0800 |
| commit | bcc7775dcace8593870e3afc12de21c871114d54 (patch) | |
| tree | 49de707bf6aad0df0a87b01f217232761e2513da | |
| parent | 86560ac84504f2da63c8fb08505857d8827684d4 (diff) | |
Add confirmation for file/folder delete. Also add New Folder button to files.
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 160 | ||||
| -rw-r--r-- | Hotline/State/HotlineState.swift | 9 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilesView.swift | 124 |
3 files changed, 182 insertions, 111 deletions
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index cd34d9e..1faaf55 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -152,15 +152,6 @@ public actor HotlineClient { // Keep-alive timer private var keepAliveTask: Task<Void, Never>? - // MARK: - Static Handshake - - private static let handshakeData = Data(endian: .big, { - "TRTP".fourCharCode() // 'TRTP' protocol ID - "HOTL".fourCharCode() // 'HOTL' sub-protocol ID - UInt16(0x0001) // Version - UInt16(0x0002) // Sub-version - }) - // Transaction IDs private var nextTransactionID: UInt32 = 1 private func generateTransactionID() -> UInt32 { @@ -199,7 +190,12 @@ public actor HotlineClient { // Perform handshake print("HotlineClient.connect(): Sending handshake...") - try await socket.write(handshakeData) + try await socket.write(Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + UInt16(0x0001) // Version + UInt16(0x0002) // Sub-version + })) let handshakeResponse = try await socket.read(8) print("HotlineClient.connect(): Handshake response received") @@ -628,40 +624,82 @@ public actor HotlineClient { return files } - - /// Request to download a file + + /// Get detailed information about a file /// /// - Parameters: /// - name: File name /// - path: Directory path containing the file - /// - preview: Request preview/thumbnail instead of full file - /// - Returns: Transfer info (reference number, size, waiting count) - public func downloadFile( - name: String, - path: [String], - preview: Bool = false - ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) + /// - Returns: File details or nil if not found + public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - if preview { - transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - let reply = try await self.sendTransaction(transaction) + let reply = try await sendTransaction(transaction) guard - let transferSize = reply.getField(type: .transferSize)?.getInteger(), - let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = reply.getField(type: .fileTypeString)?.getString(), + let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), + let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) else { - throw HotlineClientError.invalidResponse + return nil } - let fileSize = reply.getField(type: .fileSize)?.getInteger() - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - return (referenceNumber, transferSize, fileSize, waitingCount) + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - path: Directory path containing the item + /// - Returns: True if deletion succeeded + public func deleteFile(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } + } + + /// Create a folder + /// + /// - Parameters: + /// - name: New folder name + /// - path: Directory path for the new folder + /// - Returns: True if creation succeeded + public func newFolder(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } } // MARK: - News @@ -787,66 +825,6 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) } - // MARK: - File Operations - - /// Get detailed information about a file - /// - /// - Parameters: - /// - name: File name - /// - path: Directory path containing the file - /// - Returns: File details or nil if not found - public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await sendTransaction(transaction) - - guard - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - return nil - } - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - return FileDetails( - name: fileName, - path: path, - size: fileSize, - comment: fileComment, - type: fileType, - creator: fileCreator, - created: fileCreateDate, - modified: fileModifyDate - ) - } - - /// Delete a file or folder - /// - /// - Parameters: - /// - name: File or folder name - /// - path: Directory path containing the item - /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } - } - // MARK: - Administration /// Get list of user accounts (requires admin access) diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7335f37..5d6471c 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -835,6 +835,15 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } + + @MainActor + func newFolder(name: String, parentPath: [String]) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.newFolder(name: name, path: parentPath) + } @MainActor func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 984b120..3732d9a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,9 +2,36 @@ import SwiftUI import UniformTypeIdentifiers import AppKit - - - +struct NewFolderSheet: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled" + + var body: some View { + Form { + TextField(text: self.$folderName) { + Text("Folder Name") + } + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("New Folder") { + self.dismiss() + self.action?(self.folderName) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} struct FilesView: View { @Environment(HotlineState.self) private var model: HotlineState @@ -16,6 +43,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false + @State private var deleteConfirmationDisplayed: Bool = false + @State private var newFolderSheetDisplayed: Bool = false var body: some View { NavigationStack { @@ -98,13 +127,9 @@ struct FilesView: View { Divider() Button { - if let s = selectedFile { - Task { - await deleteFile(s) - } - } + self.deleteConfirmationDisplayed = true } label: { - Label("Delete", systemImage: "trash") + Label("Delete...", systemImage: "trash") } .disabled(selectedFile == nil) } @@ -154,38 +179,44 @@ struct FilesView: View { .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { - ToolbarItemGroup(placement: .automatic) { + ToolbarItem { Button { if let selectedFile = selection, selectedFile.isPreviewable { - previewFile(selectedFile) + self.previewFile(selectedFile) } } label: { Label("Preview", systemImage: "eye") } .help("Preview") .disabled(selection == nil || selection?.isPreviewable != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - getFileInfo(selectedFile) + self.getFileInfo(selectedFile) } } label: { Label("Get Info", systemImage: "info.circle") } .help("Get Info") .disabled(selection == nil) - + } + + ToolbarItem { Button { - uploadFileSelectorDisplayed = true + self.uploadFileSelectorDisplayed = true } label: { Label("Upload", systemImage: "arrow.up") } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - downloadFile(selectedFile) + self.downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") @@ -193,9 +224,48 @@ struct FilesView: View { .help("Download") .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } + + if #available(macOS 26.0, *) { + ToolbarSpacer() + } + + ToolbarItem { + Button { + self.newFolderSheetDisplayed = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + } + + ToolbarItem { + Button { + self.deleteConfirmationDisplayed = 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.$deleteConfirmationDisplayed, actions: { + Button("Delete", role: .destructive) { + if let s = self.selection { + Task { + await self.deleteFile(s) + } + } + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(isPresented: self.$newFolderSheetDisplayed) { + NewFolderSheet { folderName in + self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails ) { item in + .sheet(item: $fileDetails) { item in FileDetailsView(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in @@ -385,6 +455,20 @@ struct FilesView: View { } } + @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) == true { + 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) { @@ -405,10 +489,10 @@ struct FilesView: View { } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in + self.model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) + try? await self.model.getFileList(path: path) } } } |