Skip to content

Add breadcrumb dropdown menu #249

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CodeEdit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -122,6 +123,7 @@
D72E1A8227E3B0D400EB11B9 /* WelcomeView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = "<group>"; tabWidth = 4; };
D72E1A8627E4242900EB11B9 /* RecentProjectsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsView.swift; sourceTree = "<group>"; };
D72E1A8827E44D7C00EB11B9 /* WelcomeWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeWindowView.swift; sourceTree = "<group>"; };
D76D11CB27F2F2B6009FE61F /* BreadcrumbsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbsMenu.swift; sourceTree = "<group>"; };
D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Ranges.swift"; sourceTree = "<group>"; };
D7E201AF27E8C07300CB86D0 /* FindNavigatorSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorSearchBar.swift; sourceTree = "<group>"; };
D7E201B127E8D50000CB86D0 /* FindNavigatorModeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorModeSelector.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -221,6 +223,7 @@
children = (
2875A46C27E3BE5B007805F8 /* BreadcrumbsView.swift */,
286620A427E4AB6900E18C2B /* BreadcrumbsComponent.swift */,
D76D11CB27F2F2B6009FE61F /* BreadcrumbsMenu.swift */,
);
path = Breadcrumbs;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
69 changes: 55 additions & 14 deletions CodeEdit/Breadcrumbs/BreadcrumbsComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
}
83 changes: 83 additions & 0 deletions CodeEdit/Breadcrumbs/BreadcrumbsMenu.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
45 changes: 12 additions & 33 deletions CodeEdit/Breadcrumbs/BreadcrumbsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion CodeEdit/Documents/WorkspaceDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CodeEditModules/Modules/WorkspaceClient/src/Live.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion CodeEditModules/Modules/WorkspaceClient/src/Model/FileItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down