aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-11 17:14:13 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-11 17:14:13 -0800
commitbcc7775dcace8593870e3afc12de21c871114d54 (patch)
tree49de707bf6aad0df0a87b01f217232761e2513da
parent86560ac84504f2da63c8fb08505857d8827684d4 (diff)
Add confirmation for file/folder delete. Also add New Folder button to files.
-rw-r--r--Hotline/Hotline/HotlineClient.swift160
-rw-r--r--Hotline/State/HotlineState.swift9
-rw-r--r--Hotline/macOS/Files/FilesView.swift124
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)
}
}
}