Show file list and contents

This commit is contained in:
Christoph Hagen 2024-12-05 09:17:33 +01:00
parent c3309197c0
commit f2d78aef93
8 changed files with 220 additions and 18 deletions

View File

@ -63,6 +63,9 @@
E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */; };
E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */; };
E25DA5712D01015400AEF16D /* GenerationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */; };
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5722D018AA100AEF16D /* FileContentView.swift */; };
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5742D018B6100AEF16D /* FileDetailView.swift */; };
E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5762D018B9500AEF16D /* File+Mock.swift */; };
E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; };
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; };
E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; };
@ -170,6 +173,9 @@
E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarSettingsView.swift; sourceTree = "<group>"; };
E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedSettingsView.swift; sourceTree = "<group>"; };
E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationSettingsView.swift; sourceTree = "<group>"; };
E25DA5722D018AA100AEF16D /* FileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContentView.swift; sourceTree = "<group>"; };
E25DA5742D018B6100AEF16D /* FileDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDetailView.swift; sourceTree = "<group>"; };
E25DA5762D018B9500AEF16D /* File+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "File+Mock.swift"; sourceTree = "<group>"; };
E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; };
E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; };
E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = "<group>"; };
@ -336,6 +342,8 @@
isa = PBXGroup;
children = (
E2A21C532CBBF87A0060935B /* FilesView.swift */,
E25DA5722D018AA100AEF16D /* FileContentView.swift */,
E25DA5742D018B6100AEF16D /* FileDetailView.swift */,
);
path = Files;
sourceTree = "<group>";
@ -496,6 +504,7 @@
E2DD047C2C276F32003BFF1F /* Preview Content */ = {
isa = PBXGroup;
children = (
E25DA5762D018B9500AEF16D /* File+Mock.swift */,
E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */,
E218500A2CEE02FA0090B18B /* Content+Mock.swift */,
E2A37D1A2CEA45530000979F /* Tag+Mock.swift */,
@ -589,6 +598,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */,
E218502B2CF790B30090B18B /* PostContentView.swift in Sources */,
E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */,
E2A21C162CB1A3C90060935B /* PostImageGalleryView.swift in Sources */,
@ -644,12 +654,14 @@
E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */,
E2A21C202CB28ED20060935B /* MockImage.swift in Sources */,
E2A21C2C2CB2BB250060935B /* PostList.swift in Sources */,
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
E218500D2CEE07180090B18B /* ColorPalette.swift in Sources */,
E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */,
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */,
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,

View File

@ -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)
}
}

View File

@ -0,0 +1,7 @@
extension FileResource {
static var mock: FileResource {
.init(uniqueId: "my-file.txt", description: "Some text file")
}
}

View 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

View 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)
}

View 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)
}

View File

@ -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)
}

View File

@ -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