Show file list and contents
This commit is contained in:
@ -14,3 +14,22 @@ final class FileResource: ObservableObject {
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
extension FileResource: Identifiable {
|
||||
|
||||
var id: String { uniqueId }
|
||||
}
|
||||
|
||||
extension FileResource: Equatable {
|
||||
|
||||
static func == (lhs: FileResource, rhs: FileResource) -> Bool {
|
||||
lhs.uniqueId == rhs.uniqueId
|
||||
}
|
||||
}
|
||||
|
||||
extension FileResource: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(uniqueId)
|
||||
}
|
||||
}
|
||||
|
7
CHDataManagement/Preview Content/File+Mock.swift
Normal file
7
CHDataManagement/Preview Content/File+Mock.swift
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
extension FileResource {
|
||||
|
||||
static var mock: FileResource {
|
||||
.init(uniqueId: "my-file.txt", description: "Some text file")
|
||||
}
|
||||
}
|
@ -7,6 +7,30 @@ enum SecurityScopeBookmark: String {
|
||||
case contentPath = "contentPathBookmark"
|
||||
}
|
||||
|
||||
enum StorageAccessError: Error {
|
||||
|
||||
case noBookmarkData
|
||||
|
||||
case bookmarkDataCorrupted(Error)
|
||||
|
||||
case folderAccessFailed(URL)
|
||||
|
||||
}
|
||||
|
||||
extension StorageAccessError: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noBookmarkData:
|
||||
return "No bookmark data to access resources in folder"
|
||||
case .bookmarkDataCorrupted(let error):
|
||||
return "Failed to resolve bookmark: \(error)"
|
||||
case .folderAccessFailed(let url):
|
||||
return "Failed to access folder: \(url.path())"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
A class that handles the storage of the website data.
|
||||
|
||||
@ -218,6 +242,9 @@ final class Storage {
|
||||
filesFolder.appending(path: file, directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
/**
|
||||
Copy an external file to the content folder
|
||||
*/
|
||||
@discardableResult
|
||||
func copyFile(at url: URL, fileId: String) -> Bool {
|
||||
let contentUrl = fileUrl(file: fileId)
|
||||
@ -234,6 +261,15 @@ final class Storage {
|
||||
try deleteFiles(in: filesFolder, notIn: Set(fileSet))
|
||||
}
|
||||
|
||||
func fileContent(for file: String) throws -> String {
|
||||
try operate(in: .contentPath) { folder in
|
||||
let fileUrl = folder
|
||||
.appending(path: "files", directoryHint: .isDirectory)
|
||||
.appending(path: file, directoryHint: .notDirectory)
|
||||
return try String(contentsOf: fileUrl, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Website data
|
||||
|
||||
private var settingsDataUrl: URL {
|
||||
@ -284,18 +320,25 @@ final class Storage {
|
||||
}
|
||||
|
||||
func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool {
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
|
||||
print("No bookmark data to access folder")
|
||||
do {
|
||||
return try operate(in: scope, operation: operation)
|
||||
} catch {
|
||||
print(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func operate<T>(in scope: SecurityScopeBookmark, operation: (URL) throws -> T) throws -> T {
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
|
||||
throw StorageAccessError.noBookmarkData
|
||||
}
|
||||
var isStale = false
|
||||
let folderURL: URL
|
||||
let folderUrl: URL
|
||||
do {
|
||||
// Resolve the bookmark to get the folder URL
|
||||
folderURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
||||
folderUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
||||
} catch {
|
||||
print("Failed to resolve bookmark: \(error)")
|
||||
return false
|
||||
throw StorageAccessError.bookmarkDataCorrupted(error)
|
||||
}
|
||||
|
||||
if isStale {
|
||||
@ -303,14 +346,11 @@ final class Storage {
|
||||
}
|
||||
|
||||
// Start accessing the security-scoped resource
|
||||
if folderURL.startAccessingSecurityScopedResource() {
|
||||
let result = operation(folderURL)
|
||||
folderURL.stopAccessingSecurityScopedResource()
|
||||
return result
|
||||
} else {
|
||||
print("Failed to access folder: \(folderURL.path)")
|
||||
return false
|
||||
guard folderUrl.startAccessingSecurityScopedResource() else {
|
||||
throw StorageAccessError.folderAccessFailed(folderUrl)
|
||||
}
|
||||
defer { folderUrl.stopAccessingSecurityScopedResource() }
|
||||
return try operation(folderUrl)
|
||||
}
|
||||
|
||||
// MARK: Writing files
|
||||
|
39
CHDataManagement/Views/Files/FileContentView.swift
Normal file
39
CHDataManagement/Views/Files/FileContentView.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FileContentView: View {
|
||||
|
||||
@ObservedObject
|
||||
var file: FileResource
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@State
|
||||
private var fileContent: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if fileContent != "" {
|
||||
TextEditor(text: $fileContent)
|
||||
.font(.body.monospaced())
|
||||
.textEditorStyle(.plain)
|
||||
} else {
|
||||
Text("The file is not a text file")
|
||||
.onAppear(perform: loadFileContent)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
|
||||
private func loadFileContent() {
|
||||
do {
|
||||
fileContent = try content.storage.fileContent(for: file.uniqueId)
|
||||
} catch {
|
||||
print(error)
|
||||
fileContent = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FileContentView(file: .mock)
|
||||
}
|
26
CHDataManagement/Views/Files/FileDetailView.swift
Normal file
26
CHDataManagement/Views/Files/FileDetailView.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FileDetailView: View {
|
||||
|
||||
@ObservedObject
|
||||
var file: FileResource
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("File Name")
|
||||
.font(.headline)
|
||||
TextField("", text: $file.uniqueId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.bottom)
|
||||
.disabled(true)
|
||||
Text("Description")
|
||||
.font(.headline)
|
||||
TextField("", text: $file.description)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FileDetailView(file: .mock)
|
||||
}
|
@ -3,18 +3,76 @@ import SwiftUI
|
||||
struct FilesView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var content: Content
|
||||
private var content: Content
|
||||
|
||||
@State
|
||||
private var selected: FileResource? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack {
|
||||
|
||||
NavigationSplitView {
|
||||
List(content.files, selection: $selected) { file in
|
||||
Text(file.uniqueId)
|
||||
.tag(file)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: openFilePanel) {
|
||||
Label("Add file", systemSymbol: .plus)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 250)
|
||||
} content: {
|
||||
if let selected {
|
||||
FileContentView(file: selected)
|
||||
.id(selected.uniqueId)
|
||||
} else {
|
||||
Text("Select a file")
|
||||
}
|
||||
} detail: {
|
||||
if let selected {
|
||||
FileDetailView(file: selected)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
}
|
||||
|
||||
private func openFilePanel() {
|
||||
let panel = NSOpenPanel()
|
||||
// Sets up so user can only select a single directory
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.showsHiddenFiles = false
|
||||
panel.title = "Select files to add"
|
||||
panel.prompt = ""
|
||||
|
||||
let response = panel.runModal()
|
||||
guard response == .OK else {
|
||||
print("Failed to select files to import")
|
||||
return
|
||||
}
|
||||
|
||||
for url in panel.urls {
|
||||
let fileId = url.lastPathComponent
|
||||
guard !content.files.contains(where: { $0.uniqueId == fileId }) else {
|
||||
print("A file '\(fileId)' already exists")
|
||||
continue
|
||||
}
|
||||
let file = FileResource(uniqueId: fileId, description: "")
|
||||
guard content.storage.copyFile(at: url, fileId: fileId) else {
|
||||
print("Failed to import file '\(fileId)'")
|
||||
continue
|
||||
}
|
||||
content.files.insert(file, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FilesView()
|
||||
.environmentObject(Content.mock)
|
||||
}
|
||||
|
@ -43,6 +43,7 @@ struct PageListView: View {
|
||||
} content: {
|
||||
if let selected {
|
||||
PageDetailView(page: selected)
|
||||
.id(selected.id)
|
||||
.layoutPriority(1)
|
||||
} else {
|
||||
// Fallback if no item is selected
|
||||
|
Reference in New Issue
Block a user