diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 1d8e4c58b..f8b21c1f8 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ D72E1A8327E3B0D400EB11B9 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E1A8227E3B0D400EB11B9 /* WelcomeView.swift */; }; D72E1A8727E4242900EB11B9 /* RecentProjectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E1A8627E4242900EB11B9 /* RecentProjectsView.swift */; }; D72E1A8927E44D7C00EB11B9 /* WelcomeWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E1A8827E44D7C00EB11B9 /* WelcomeWindowView.swift */; }; + D76D11CC27F2F2B6009FE61F /* BreadcrumbsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76D11CB27F2F2B6009FE61F /* BreadcrumbsMenu.swift */; }; D7E201AE27E8B3C000CB86D0 /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */; }; D7E201B027E8C07300CB86D0 /* FindNavigatorSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201AF27E8C07300CB86D0 /* FindNavigatorSearchBar.swift */; }; D7E201B227E8D50000CB86D0 /* FindNavigatorModeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201B127E8D50000CB86D0 /* FindNavigatorModeSelector.swift */; }; @@ -122,6 +123,7 @@ D72E1A8227E3B0D400EB11B9 /* WelcomeView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; tabWidth = 4; }; D72E1A8627E4242900EB11B9 /* RecentProjectsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsView.swift; sourceTree = ""; }; D72E1A8827E44D7C00EB11B9 /* WelcomeWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeWindowView.swift; sourceTree = ""; }; + D76D11CB27F2F2B6009FE61F /* BreadcrumbsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbsMenu.swift; sourceTree = ""; }; D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Ranges.swift"; sourceTree = ""; }; D7E201AF27E8C07300CB86D0 /* FindNavigatorSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorSearchBar.swift; sourceTree = ""; }; D7E201B127E8D50000CB86D0 /* FindNavigatorModeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorModeSelector.swift; sourceTree = ""; }; @@ -221,6 +223,7 @@ children = ( 2875A46C27E3BE5B007805F8 /* BreadcrumbsView.swift */, 286620A427E4AB6900E18C2B /* BreadcrumbsComponent.swift */, + D76D11CB27F2F2B6009FE61F /* BreadcrumbsMenu.swift */, ); path = Breadcrumbs; sourceTree = ""; @@ -574,6 +577,7 @@ D72E1A8727E4242900EB11B9 /* RecentProjectsView.swift in Sources */, 04660F6A27E51E5C00477777 /* CodeEditWindowController.swift in Sources */, B6EE989027E8879A00CDD8AB /* InspectorSidebar.swift in Sources */, + D76D11CC27F2F2B6009FE61F /* BreadcrumbsMenu.swift in Sources */, 289978ED27E4E97E00BB0357 /* FileIconStyle.swift in Sources */, 04660F6627E3ACEF00477777 /* ReopenBehavior.swift in Sources */, D72E1A8327E3B0D400EB11B9 /* WelcomeView.swift in Sources */, diff --git a/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift b/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift index 4ed838893..a3d61d083 100644 --- a/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift +++ b/CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift @@ -6,31 +6,72 @@ // import SwiftUI +import WorkspaceClient struct BreadcrumbsComponent: View { @AppStorage(FileIconStyle.storageKey) var iconStyle: FileIconStyle = .default - private let title: String - private let image: String - private let color: Color + @ObservedObject + var workspace: WorkspaceDocument - init(_ title: String, systemImage image: String, color: Color = .secondary) { - self.title = title - self.image = image - self.color = color + private let fileItem: WorkspaceClient.FileItem + + @State + var position: NSPoint? + + init(_ workspace: WorkspaceDocument, fileItem: WorkspaceClient.FileItem) { + self.workspace = workspace + self.fileItem = fileItem + } + + private var image: String { + fileItem.parent == nil ? "square.dashed.inset.filled" : fileItem.systemImage + } + + /// If current `fileItem` has no parent, it's the workspace root directory + /// else if current `fileItem` has no children, it's the opened file + /// else it's a folder + private var color: Color { + fileItem.parent == nil + ? .accentColor + : fileItem.children?.isEmpty ?? true + ? fileItem.iconColor + : .secondary } var body: some View { - HStack { - Image(systemName: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) - .foregroundStyle(iconStyle == .color ? color : .secondary) - Text(title) + HStack(alignment: .center) { + /// Get location in window + /// Can't use it outside `HStack` becuase it'll make the whole `BreadcrumsComponent` flexiable. + GeometryReader { geometry in + HStack { + Image(systemName: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + .foregroundStyle(iconStyle == .color ? color : .secondary) + .onAppear { + self.position = NSPoint( + x: geometry.frame(in: .global).minX, + y: geometry.frame(in: .global).midY + ) + } + }.frame(height: geometry.size.height) + } + Text(fileItem.fileName) .foregroundStyle(.primary) .font(.system(size: 11)) } + .onTapGesture { + if let siblings = fileItem.parent?.children?.sortItems(foldersOnTop: true), !siblings.isEmpty { + let menu = BreadcrumsMenu(siblings, workspace: workspace) + if let position = position { + menu.popUp(positioning: menu.item(withTitle: fileItem.fileName), + at: position, + in: NSApp.keyWindow?.contentView) + } + } + } } } diff --git a/CodeEdit/Breadcrumbs/BreadcrumbsMenu.swift b/CodeEdit/Breadcrumbs/BreadcrumbsMenu.swift new file mode 100644 index 000000000..75e2f2845 --- /dev/null +++ b/CodeEdit/Breadcrumbs/BreadcrumbsMenu.swift @@ -0,0 +1,83 @@ +// +// BreadcrumbsMenu.swift +// CodeEdit +// +// Created by Ziyuan Zhao on 2022/3/29. +// + +import AppKit +import WorkspaceClient + +class BreadcrumsMenu: NSMenu, NSMenuDelegate { + + let fileItems: [WorkspaceClient.FileItem] + let workspace: WorkspaceDocument + + init(_ fileItems: [WorkspaceClient.FileItem], workspace: WorkspaceDocument) { + self.fileItems = fileItems + self.workspace = workspace + super.init(title: "") + self.delegate = self + fileItems.forEach { item in + let menuItem = BreadcrumbsMenuItem(item, workspace: workspace) + self.addItem(menuItem) + } + self.autoenablesItems = false + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Only when menu item is highlighted then generate its submenu + func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { + if let highlightedItem = item, let submenuItems = highlightedItem.submenu?.items, submenuItems.isEmpty { + if let highlightedFileItem = highlightedItem.representedObject as? WorkspaceClient.FileItem { + highlightedItem.submenu = generateSubmenu(highlightedFileItem) + } + } + } + + private func generateSubmenu(_ fileItem: WorkspaceClient.FileItem) -> BreadcrumsMenu? { + if let children = fileItem.children { + let menu = BreadcrumsMenu(children, workspace: workspace) + return menu + } + return nil + } +} + +class BreadcrumbsMenuItem: NSMenuItem { + let fileItem: WorkspaceClient.FileItem + var workspace: WorkspaceDocument + + init(_ fileItem: WorkspaceClient.FileItem, workspace: WorkspaceDocument) { + self.fileItem = fileItem + self.workspace = workspace + super.init(title: fileItem.fileName, action: #selector(openFile), keyEquivalent: "") + var icon = fileItem.fileIcon + var color = fileItem.iconColor + self.isEnabled = true + self.target = self + if fileItem.children != nil { + let subMenu = NSMenu() + self.submenu = subMenu + icon = "folder.fill" + color = .secondary + } + let image = NSImage( + systemSymbolName: icon, + accessibilityDescription: icon + )?.withSymbolConfiguration(.init(paletteColors: [NSColor(color)])) + self.image = image + self.representedObject = fileItem + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func openFile() { + workspace.openFile(item: fileItem) + } +} diff --git a/CodeEdit/Breadcrumbs/BreadcrumbsView.swift b/CodeEdit/Breadcrumbs/BreadcrumbsView.swift index 74eb1c302..4190d596b 100644 --- a/CodeEdit/Breadcrumbs/BreadcrumbsView.swift +++ b/CodeEdit/Breadcrumbs/BreadcrumbsView.swift @@ -15,16 +15,7 @@ struct BreadcrumbsView: View { let file: WorkspaceClient.FileItem @State - private var projectName: String = "" - - @State - private var folders: [String] = [] - - @State - private var fileName: String = "" - - @State - private var fileImage: String = "doc" + private var fileItems: [WorkspaceClient.FileItem] = [] init(_ file: WorkspaceClient.FileItem, workspace: WorkspaceDocument) { self.file = file @@ -37,19 +28,12 @@ struct BreadcrumbsView: View { .foregroundStyle(Color(nsColor: .controlBackgroundColor)) ScrollView(.horizontal, showsIndicators: false) { HStack { - BreadcrumbsComponent( - projectName, - systemImage: "square.dashed.inset.filled", - color: .accentColor - ) - - chevron - - ForEach(folders, id: \.self) { folder in - BreadcrumbsComponent(folder, systemImage: "folder.fill") - chevron + ForEach(fileItems, id: \.self) { fileItem in + if fileItem.parent != nil { + chevron + } + BreadcrumbsComponent(workspace, fileItem: fileItem) } - BreadcrumbsComponent(fileName, systemImage: fileImage, color: file.iconColor) } .padding(.horizontal, 12) } @@ -73,17 +57,12 @@ struct BreadcrumbsView: View { } private func fileInfo(_ file: WorkspaceClient.FileItem) { - guard let projURL = workspace.fileURL else { return } - let components = file.url.path - .replacingOccurrences(of: projURL.path, with: "") - .split(separator: "/") - .map { String($0) } - .dropLast() - - self.projectName = projURL.lastPathComponent - self.folders = Array(components) - self.fileName = file.fileName - self.fileImage = file.systemImage + self.fileItems = [] + var currentFile: WorkspaceClient.FileItem? = file + while let currentFileLoop = currentFile { + self.fileItems.insert(currentFileLoop, at: 0) + currentFile = currentFileLoop.parent + } } } diff --git a/CodeEdit/Documents/WorkspaceDocument.swift b/CodeEdit/Documents/WorkspaceDocument.swift index 8ca68bea4..0e7278989 100644 --- a/CodeEdit/Documents/WorkspaceDocument.swift +++ b/CodeEdit/Documents/WorkspaceDocument.swift @@ -138,7 +138,9 @@ class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { let state = try JSONDecoder().decode(WorkspaceSelectionState.self, from: Data(contentsOf: selectionStateFile)) self.selectionState.fileItems = state.fileItems - state.openFileItems.forEach { item in + state.openFileItems + .compactMap { try? workspaceClient?.getFileItem($0.id) } + .forEach { item in self.openFile(item: item) } self.selectionState.selectedId = state.selectedId diff --git a/CodeEditModules/Modules/WorkspaceClient/src/Live.swift b/CodeEditModules/Modules/WorkspaceClient/src/Live.swift index e637da138..94a660fae 100644 --- a/CodeEditModules/Modules/WorkspaceClient/src/Live.swift +++ b/CodeEditModules/Modules/WorkspaceClient/src/Live.swift @@ -37,6 +37,9 @@ public extension WorkspaceClient { } let newFileItem = FileItem(url: itemURL, children: subItems) + subItems?.forEach { + $0.parent = newFileItem + } items.append(newFileItem) flattenedFileItems[newFileItem.id] = newFileItem } @@ -45,6 +48,11 @@ public extension WorkspaceClient { } // initial load let fileItems = try loadFiles(fromURL: folderURL) + // workspace fileItem + let workspaceItem = FileItem(url: folderURL, children: fileItems) + fileItems.forEach { item in + item.parent = workspaceItem + } // By using `CurrentValueSubject` we can define a starting value. // The value passed during init it's going to be send as soon as the // consumer subscribes to the publisher. diff --git a/CodeEditModules/Modules/WorkspaceClient/src/Model/FileItem.swift b/CodeEditModules/Modules/WorkspaceClient/src/Model/FileItem.swift index 3c2147e7b..67e746c44 100644 --- a/CodeEditModules/Modules/WorkspaceClient/src/Model/FileItem.swift +++ b/CodeEditModules/Modules/WorkspaceClient/src/Model/FileItem.swift @@ -9,11 +9,18 @@ import Foundation import SwiftUI public extension WorkspaceClient { - struct FileItem: Hashable, Identifiable, Comparable, Codable { + enum FileItemCodingKeys: String, CodingKey { + case id + case url + case children + } + + class FileItem: Hashable, Identifiable, Comparable, Codable { // TODO: use a phantom type instead of a String public var id: String public var url: URL public var children: [FileItem]? + public var parent: FileItem? public static let fileManger = FileManager.default public var systemImage: String { switch children { @@ -149,6 +156,28 @@ public extension WorkspaceClient { } } } + + // MARK: Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(url) + hasher.combine(children) + } + + // MARK: Codable + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: FileItemCodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(url, forKey: .url) + try container.encode(children, forKey: .children) + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: FileItemCodingKeys.self) + id = try values.decode(String.self, forKey: .id) + url = try values.decode(URL.self, forKey: .url) + children = try values.decode([FileItem]?.self, forKey: .children) + } } }