1
1
mirror of https://github.com/qvacua/vimr.git synced 2024-12-25 14:52:19 +03:00

React to file additions/removals

This commit is contained in:
Tae Won Ha 2019-02-25 14:15:27 +01:00
parent ad4326dffb
commit 55bb4762fe
No known key found for this signature in database
GPG Key ID: E40743465B5B8B44
3 changed files with 565 additions and 442 deletions

View File

@ -13,6 +13,7 @@
1929B0E0C3BC59F52713D5A2 /* FoundationCommons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929B9AF20D7BD6E5C975128 /* FoundationCommons.swift */; };
1929B0F599D1F62C7BE53D2C /* HttpServerMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929B1DC584C89C477E83FA2 /* HttpServerMiddleware.swift */; };
1929B1837C750CADB3A5BCB9 /* OpenQuicklyFileViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929B1558455B3A74D93EF2A /* OpenQuicklyFileViewRow.swift */; };
1929B1A39CC79D00DC4E04B6 /* FileOutlineViewOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929B28110A57C0C1089B782 /* FileOutlineViewOld.swift */; };
1929B20CE35B43BB1CE023BA /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929BC2F05E9A5C0DB039739 /* Theme.swift */; };
1929B29B95AD176D57942E08 /* UiRootReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929B457B9D0FA4D21F3751E /* UiRootReducer.swift */; };
1929B3217A7A3D79E28C80DB /* PrefWindowReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1929B49E6924847AD085C8C9 /* PrefWindowReducer.swift */; };
@ -296,6 +297,7 @@
1929B14A5949FB64C4B2646F /* KeysPref.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeysPref.swift; sourceTree = "<group>"; };
1929B1558455B3A74D93EF2A /* OpenQuicklyFileViewRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenQuicklyFileViewRow.swift; sourceTree = "<group>"; };
1929B1DC584C89C477E83FA2 /* HttpServerMiddleware.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HttpServerMiddleware.swift; sourceTree = "<group>"; };
1929B28110A57C0C1089B782 /* FileOutlineViewOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileOutlineViewOld.swift; sourceTree = "<group>"; };
1929B34FC23D805A8B29E8F7 /* Context.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = "<group>"; };
1929B364460D86F17E80943C /* PrefMiddleware.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrefMiddleware.swift; sourceTree = "<group>"; };
1929B365A6434354B568B04F /* FileMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileMonitor.swift; sourceTree = "<group>"; };
@ -513,6 +515,7 @@
1929BCE3E156C06EDF1F2806 /* FileOutlineView.swift */,
1929BA5C7099CDEB04B76BA4 /* FileBrowser.swift */,
1929BD2CA8DD198A6BCDBCB7 /* ThemedTableSubviews.swift */,
1929B28110A57C0C1089B782 /* FileOutlineViewOld.swift */,
);
name = "File Browser";
sourceTree = "<group>";
@ -1147,6 +1150,7 @@
1929B8F498D1E7C53F572CE2 /* KeysPref.swift in Sources */,
1929B5A2EE366F79ED32744C /* KeysPrefReducer.swift in Sources */,
1929BB67CAAD4F6CBD38DF0A /* RxRedux.swift in Sources */,
1929B1A39CC79D00DC4E04B6 /* FileOutlineViewOld.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -92,11 +92,33 @@ class FileOutlineView: NSOutlineView,
})
.disposed(by: self.disposeBag)
source
.filter { !self.shouldReloadData(for: $0) }
.filter { $0.lastFileSystemUpdate.mark != self.lastFileSystemUpdateMark }
.map { $0.lastFileSystemUpdate.payload }
.throttle(2 * FileMonitor.fileSystemEventsLatency + 1,
latest: true,
scheduler: SerialDispatchQueueScheduler(qos: .background))
.map { ($0, Set(self.childUrls(for: $0))) }
.observeOn(MainScheduler.instance)
.subscribe(onNext: { (url, newChildUrls) in
guard let changeTreeNode = self.changeRootTreeNode(for: url) else {
return
}
self.handleRemoval(changeTreeNode: changeTreeNode,
newChildUrls: newChildUrls)
self.handleAddition(changeTreeNode: changeTreeNode,
newChildUrls: newChildUrls)
})
.disposed(by: self.disposeBag)
self.treeController.childrenKeyPath = "children"
self.treeController.leafKeyPath = "isLeaf"
self.treeController.countKeyPath = "childrenCount"
self.treeController.objectClass = Node.self
self.treeController.avoidsEmptySelection = false
self.treeController.preservesSelection = true
self.treeController.sortDescriptors = [
NSSortDescriptor(key: "isLeaf", ascending: true), // Folders first,
NSSortDescriptor(key: "displayName", ascending: true) // then, name
@ -110,6 +132,64 @@ class FileOutlineView: NSOutlineView,
self.reloadRoot()
}
private func changeRootTreeNode(`for` url: URL) -> NSTreeNode? {
if url == self.cwd {
return self.treeController.arrangedObjects
}
let comps = url.pathComponents.suffix(
from: self.cwd.pathComponents.count
)
let rootTreeNode = self.treeController.arrangedObjects
let changeTreeNode = comps.reduce(rootTreeNode) { (prev, comp) in
return prev.children?.first { child in
return child.node?.displayName == comp
} ?? prev
}
guard let changeNode = changeTreeNode.node else {
return nil
}
guard changeNode.url == url && changeNode.children != nil else {
return nil
}
return changeTreeNode
}
private func handleAddition(
changeTreeNode: NSTreeNode, newChildUrls: Set<URL>
) {
let existingUrls = changeTreeNode.children?
.compactMap { $0.node?.url } ?? []
let newNodes = newChildUrls
.subtracting(existingUrls)
.map { Node(url: $0) }
let newIndexPaths = (0..<newNodes.count)
.map { i in changeTreeNode.indexPath.appending(i) }
self.treeController.insert(newNodes,
atArrangedObjectIndexPaths: newIndexPaths)
}
private func handleRemoval(
changeTreeNode: NSTreeNode, newChildUrls: Set<URL>
) {
let indexPathsToRemove =
changeTreeNode.children?
.filter { child in
guard let url = child.node?.url else { return true }
return newChildUrls.contains(url) == false
}
.map { $0.indexPath } ?? []
self.treeController.removeObjects(
atArrangedObjectIndexPaths: indexPathsToRemove
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -152,6 +232,18 @@ class FileOutlineView: NSOutlineView,
private var triangleClosed: NSImage
private var triangleOpen: NSImage
private func childUrls(for url: URL) -> [URL] {
let urls = FileUtils.directDescendants(of: url).sorted { lhs, rhs in
return lhs.lastPathComponent < rhs.lastPathComponent
}
if self.isShowHidden {
return urls
}
return urls.filter { !$0.isHidden }
}
private func childNodes(for node: Node) -> [Node] {
if node.isChildrenScanned {
return node.children ?? []
@ -217,7 +309,7 @@ class FileOutlineView: NSOutlineView,
}
private func node(from item: Any?) -> Node? {
return (item as? NSTreeNode)?.representedObject as? Node
return (item as? NSTreeNode)?.node
}
}
@ -440,7 +532,15 @@ extension FileOutlineView {
}
}
class Node: NSObject {
class Node: NSObject, Comparable {
// static func ==(left: Node, right: Node) -> Bool {
// return left.url == right.url
// }
static func <(lhs: Node, rhs: Node) -> Bool {
return lhs.displayName < rhs.displayName
}
@objc dynamic var url: URL
@objc dynamic var isLeaf: Bool
@ -459,6 +559,10 @@ class Node: NSObject {
}
var isChildrenScanned = false
override var description: String {
return "<Node: \(self.url): \(self.childrenCount) children>"
}
override var hash: Int {
return self.url.hashValue
}
@ -470,447 +574,10 @@ class Node: NSObject {
}
}
// MARK: - OLD
class FileOutlineViewOld: NSOutlineView,
UiComponent,
NSOutlineViewDataSource,
NSOutlineViewDelegate,
ThemedView {
fileprivate extension NSTreeNode {
typealias StateType = MainWindow.State
private(set) var theme = Theme.default
required init(
source: Observable<StateType>,
emitter: ActionEmitter,
state: StateType) {
self.emit = emitter.typedEmit()
self.uuid = state.uuid
self.root = FileBrowserItem(state.cwd)
self.isShowHidden = state.fileBrowserShowHidden
self.usesTheme = state.appearance.usesTheme
self.showsFileIcon = state.appearance.showsFileIcon
super.init(frame: .zero)
NSOutlineView.configure(toStandard: self)
self.dataSource = self
self.delegate = self
self.allowsEmptySelection = true
guard Bundle.main.loadNibNamed(
NSNib.Name("FileBrowserMenu"),
owner: self,
topLevelObjects: nil
) else {
NSLog("WARN: FileBrowserMenu.xib could not be loaded")
return
}
// If the target of the menu items is set to the first responder,
// the actions are not invoked at all when the file monitor fires
// in the background... Dunno why it worked before the redesign... -_-
self.menu?.items.forEach { $0.target = self }
// self.doubleAction = #selector(FileOutlineViewOld.doubleClickAction)
source
.filter { !self.shouldReloadData(for: $0) }
.filter { $0.lastFileSystemUpdate.mark != self.lastFileSystemUpdateMark }
.throttle(2 * FileMonitor.fileSystemEventsLatency + 1,
latest: true,
scheduler: SerialDispatchQueueScheduler(qos: .background))
.observeOn(MainScheduler.instance)
.subscribe(onNext: { state in
self.lastFileSystemUpdateMark = state.lastFileSystemUpdate.mark
guard let fileBrowserItem = self.fileBrowserItem(
with: state.lastFileSystemUpdate.payload
) else {
return
}
self.update(fileBrowserItem)
})
.disposed(by: self.disposeBag)
source
.observeOn(MainScheduler.instance)
.subscribe(onNext: { state in
if state.viewToBeFocused != nil,
case .fileBrowser = state.viewToBeFocused! {
self.beFirstResponder()
}
let themeChanged = changeTheme(
themePrefChanged: state.appearance.usesTheme != self.usesTheme,
themeChanged: state.appearance.theme.mark != self.lastThemeMark,
usesTheme: state.appearance.usesTheme,
forTheme: { self.updateTheme(state.appearance.theme) },
forDefaultTheme: { self.updateTheme(Marked(Theme.default)) })
self.usesTheme = state.appearance.usesTheme
guard self.shouldReloadData(
for: state, themeChanged: themeChanged
) else {
return
}
self.showsFileIcon = state.appearance.showsFileIcon
self.isShowHidden = state.fileBrowserShowHidden
self.lastFileSystemUpdateMark = state.lastFileSystemUpdate.mark
self.root = FileBrowserItem(state.cwd)
self.reloadData()
})
.disposed(by: self.disposeBag)
}
override func reloadData() {
self.cells.removeAll()
self.widths.removeAll()
super.reloadData()
}
func select(_ url: URL) {
var stack = [self.root]
while let item = stack.popLast() {
self.expandItem(item)
if item.url.isDirectParent(of: url) {
if let targetItem = item.children.first(where: { $0.url == url }) {
let targetRow = self.row(forItem: targetItem)
self.selectRowIndexes(
IndexSet(integer: targetRow), byExtendingSelection: false
)
self.scrollRowToVisible(targetRow)
}
break
}
stack.append(contentsOf: item.children.filter {
$0.url.isParent(of: url)
})
}
}
private let emit: (UuidAction<FileBrowser.Action>) -> Void
private let disposeBag = DisposeBag()
private let uuid: String
private var lastFileSystemUpdateMark = Token()
private var usesTheme: Bool
private(set) var lastThemeMark = Token()
private var showsFileIcon: Bool
private var cwd: URL {
return self.root.url
}
private var isShowHidden: Bool
private var root: FileBrowserItem
private var widths = [String: CGFloat]()
private var cells = [String: ThemedTableCell]()
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateTheme(_ theme: Marked<Theme>) {
self.theme = theme.payload
self.enclosingScrollView?.backgroundColor = self.theme.background
self.backgroundColor = self.theme.background
self.lastThemeMark = theme.mark
}
private func shouldReloadData(
for state: StateType, themeChanged: Bool = false
) -> Bool {
if self.isShowHidden != state.fileBrowserShowHidden {
return true
}
if themeChanged {
return true
}
if self.showsFileIcon != state.appearance.showsFileIcon {
return true
}
if state.cwd != self.cwd {
return true
}
return false
}
private func handleRemovals(for fileBrowserItem: FileBrowserItem,
new newChildren: [FileBrowserItem]) {
let curChildren = fileBrowserItem.children
let curPreparedChildren = self.prepare(curChildren)
let newPreparedChildren = self.prepare(newChildren)
let indicesToRemove = curPreparedChildren
.enumerated()
.filter { (_, fileBrowserItem) in
newPreparedChildren.contains(fileBrowserItem) == false
}
.map { (idx, _) in idx }
indicesToRemove.forEach { index in
let path = curPreparedChildren[index].url.path
self.cells.removeValue(forKey: path)
self.widths.removeValue(forKey: path)
}
fileBrowserItem.children = curChildren.filter { newChildren.contains($0) }
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
self.removeItems(at: IndexSet(indicesToRemove), inParent: parent)
}
private func handleAdditions(for fileBrowserItem: FileBrowserItem,
new newChildren: [FileBrowserItem]) {
let curChildren = fileBrowserItem.children
let curPreparedChildren = self.prepare(curChildren)
let newPreparedChildren = self.prepare(newChildren)
let indicesToInsert = newPreparedChildren
.enumerated()
.filter { (_, fileBrowserItem) in
curPreparedChildren.contains(fileBrowserItem) == false
}
.map { (idx, _) in idx }
// We don't just take newChildren because NSOutlineView look
// at the pointer equality for preserving the expanded states...
fileBrowserItem.children = newChildren.substituting(elements: curChildren)
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
self.insertItems(at: IndexSet(indicesToInsert), inParent: parent)
}
private func sortedChildren(of url: URL) -> [FileBrowserItem] {
return FileUtils.directDescendants(of: url)
.map(FileBrowserItem.init).sorted {
if ($0.isDir == $1.isDir) {
return $0.url.absoluteString < $1.url.absoluteString
}
return $0.isDir
}
}
private func update(_ fileBrowserItem: FileBrowserItem) {
let url = fileBrowserItem.url
// Sort the array to keep the order.
let newChildren = self.sortedChildren(of: url)
self.beginUpdates()
self.handleRemovals(for: fileBrowserItem, new: newChildren)
self.endUpdates()
self.beginUpdates()
self.handleAdditions(for: fileBrowserItem, new: newChildren)
self.endUpdates()
fileBrowserItem.isChildrenScanned = true
fileBrowserItem.children
.filter { self.isItemExpanded($0) }
.forEach(self.update)
}
private func fileBrowserItem(with url: URL) -> FileBrowserItem? {
if self.cwd == url {
return self.root
}
guard self.cwd.isParent(of: url) else {
return nil
}
let rootPathComps = self.cwd.pathComponents
let pathComps = url.pathComponents
let childPart = pathComps[rootPathComps.count..<pathComps.count]
return childPart
.reduce(self.root) { (resultItem, childName) -> FileBrowserItem? in
guard let parent = resultItem else {
return nil
}
return parent.child(with: parent.url.appendingPathComponent(childName))
}
}
}
// MARK: - NSOutlineViewDataSource
extension FileOutlineViewOld {
private func scanChildrenIfNecessary(_ fileBrowserItem: FileBrowserItem) {
guard fileBrowserItem.isChildrenScanned == false else {
return
}
fileBrowserItem.children = self.sortedChildren(of: fileBrowserItem.url)
fileBrowserItem.isChildrenScanned = true
}
private func prepare(_ children: [FileBrowserItem]) -> [FileBrowserItem] {
return self.isShowHidden ? children : children.filter { !$0.isHidden }
}
func outlineView(_: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil {
self.scanChildrenIfNecessary(self.root)
return self.prepare(self.root.children).count
}
guard let fileBrowserItem = item as? FileBrowserItem else {
return 0
}
if fileBrowserItem.url.isDir {
self.scanChildrenIfNecessary(fileBrowserItem)
return self.prepare(fileBrowserItem.children).count
}
return 0
}
func outlineView(
_: NSOutlineView, child index: Int, ofItem item: Any?
) -> Any {
let level = self.level(forItem: item) + 2
defer { self.adjustColumnWidths() }
if item == nil {
let child = self.prepare(self.root.children)[index]
let cell = self.cell(forItem: child)
self.cells[child.url.path] = cell
self.widths[child.url.path] = self.cellWidth(for: cell, level: level)
return child
}
guard let fileBrowserItem = item as? FileBrowserItem else {
preconditionFailure("Should not happen")
}
let child = self.prepare(fileBrowserItem.children)[index]
let cell = self.cell(forItem: child)
self.cells[child.url.path] = cell
self.widths[child.url.path] = self.cellWidth(for: cell, level: level)
return child
}
private func cell(forItem item: FileBrowserItem) -> ThemedTableCell {
if let existingCell = self.cells[item.url.path] {
return existingCell
}
let cell = ThemedTableCell(withIdentifier: "file-cell-view")
cell.isDir = item.isDir
cell.text = item.url.lastPathComponent
if self.showsFileIcon {
let icon = FileUtils.icon(forUrl: item.url)
cell.image = cell.isHidden
? icon?.tinting(with: NSColor.white.withAlphaComponent(0.4))
: icon
}
return cell
}
func outlineView(_: NSOutlineView, isItemExpandable item: Any) -> Bool {
guard let fileBrowserItem = item as? FileBrowserItem else {
return false
}
return fileBrowserItem.url.isDir
}
@objc(outlineView:objectValueForTableColumn:byItem:)
func outlineView(_: NSOutlineView,
objectValueFor: NSTableColumn?,
byItem item: Any?) -> Any? {
guard let fileBrowserItem = item as? FileBrowserItem else {
return nil
}
return fileBrowserItem
}
private func cellWidth(for cell: NSView?, level: Int) -> CGFloat {
let cellWidth = cell?.intrinsicContentSize.width ?? 0
let indentation = CGFloat(level + 1) * self.indentationPerLevel + 4
return cellWidth + indentation
}
private func adjustColumnWidths() {
guard let column = self.outlineTableColumn else {
return
}
column.minWidth = self.widths.values.max() ?? 100
column.maxWidth = self.widths.values.max() ?? 100
}
}
private class FileBrowserItem: Hashable, Comparable, CustomStringConvertible {
static func ==(left: FileBrowserItem, right: FileBrowserItem) -> Bool {
return left.url == right.url
}
static func <(left: FileBrowserItem, right: FileBrowserItem) -> Bool {
return left.url.lastPathComponent < right.url.lastPathComponent
}
var hashValue: Int {
return self.url.hashValue
}
var description: String {
return self.url.path
}
let url: URL
let isDir: Bool
let isHidden: Bool
var children: [FileBrowserItem] = []
var isChildrenScanned = false
init(_ url: URL) {
self.url = url
// We cache the value here since we often get the value when the file is
// not there, eg when updating because the file gets deleted
// (in self.prepare() function)
self.isHidden = url.isHidden
self.isDir = url.isDir
}
func child(with url: URL) -> FileBrowserItem? {
return self.children.first { $0.url == url }
var node: Node? {
return self.representedObject as? Node
}
}

View File

@ -0,0 +1,452 @@
//
// Created by Tae Won Ha on 2019-02-25.
// Copyright (c) 2019 Tae Won Ha. All rights reserved.
//
import Cocoa
import NvimView
import PureLayout
import RxSwift
class FileOutlineViewOld: NSOutlineView,
UiComponent,
NSOutlineViewDataSource,
NSOutlineViewDelegate,
ThemedView {
typealias StateType = MainWindow.State
private(set) var theme = Theme.default
required init(
source: Observable<StateType>,
emitter: ActionEmitter,
state: StateType) {
self.emit = emitter.typedEmit()
self.uuid = state.uuid
self.root = FileBrowserItem(state.cwd)
self.isShowHidden = state.fileBrowserShowHidden
self.usesTheme = state.appearance.usesTheme
self.showsFileIcon = state.appearance.showsFileIcon
super.init(frame: .zero)
NSOutlineView.configure(toStandard: self)
self.dataSource = self
self.delegate = self
self.allowsEmptySelection = true
guard Bundle.main.loadNibNamed(
NSNib.Name("FileBrowserMenu"),
owner: self,
topLevelObjects: nil
) else {
NSLog("WARN: FileBrowserMenu.xib could not be loaded")
return
}
// If the target of the menu items is set to the first responder,
// the actions are not invoked at all when the file monitor fires
// in the background... Dunno why it worked before the redesign... -_-
self.menu?.items.forEach { $0.target = self }
// self.doubleAction = #selector(FileOutlineViewOld.doubleClickAction)
source
.filter { !self.shouldReloadData(for: $0) }
.filter { $0.lastFileSystemUpdate.mark != self.lastFileSystemUpdateMark }
.throttle(2 * FileMonitor.fileSystemEventsLatency + 1,
latest: true,
scheduler: SerialDispatchQueueScheduler(qos: .background))
.observeOn(MainScheduler.instance)
.subscribe(onNext: { state in
self.lastFileSystemUpdateMark = state.lastFileSystemUpdate.mark
guard let fileBrowserItem = self.fileBrowserItem(
with: state.lastFileSystemUpdate.payload
) else {
return
}
self.update(fileBrowserItem)
})
.disposed(by: self.disposeBag)
source
.observeOn(MainScheduler.instance)
.subscribe(onNext: { state in
if state.viewToBeFocused != nil,
case .fileBrowser = state.viewToBeFocused! {
self.beFirstResponder()
}
let themeChanged = changeTheme(
themePrefChanged: state.appearance.usesTheme != self.usesTheme,
themeChanged: state.appearance.theme.mark != self.lastThemeMark,
usesTheme: state.appearance.usesTheme,
forTheme: { self.updateTheme(state.appearance.theme) },
forDefaultTheme: { self.updateTheme(Marked(Theme.default)) })
self.usesTheme = state.appearance.usesTheme
guard self.shouldReloadData(
for: state, themeChanged: themeChanged
) else {
return
}
self.showsFileIcon = state.appearance.showsFileIcon
self.isShowHidden = state.fileBrowserShowHidden
self.lastFileSystemUpdateMark = state.lastFileSystemUpdate.mark
self.root = FileBrowserItem(state.cwd)
self.reloadData()
})
.disposed(by: self.disposeBag)
}
override func reloadData() {
self.cells.removeAll()
self.widths.removeAll()
super.reloadData()
}
func select(_ url: URL) {
var stack = [self.root]
while let item = stack.popLast() {
self.expandItem(item)
if item.url.isDirectParent(of: url) {
if let targetItem = item.children.first(where: { $0.url == url }) {
let targetRow = self.row(forItem: targetItem)
self.selectRowIndexes(
IndexSet(integer: targetRow), byExtendingSelection: false
)
self.scrollRowToVisible(targetRow)
}
break
}
stack.append(contentsOf: item.children.filter {
$0.url.isParent(of: url)
})
}
}
private let emit: (UuidAction<FileBrowser.Action>) -> Void
private let disposeBag = DisposeBag()
private let uuid: String
private var lastFileSystemUpdateMark = Token()
private var usesTheme: Bool
private(set) var lastThemeMark = Token()
private var showsFileIcon: Bool
private var cwd: URL {
return self.root.url
}
private var isShowHidden: Bool
private var root: FileBrowserItem
private var widths = [String: CGFloat]()
private var cells = [String: ThemedTableCell]()
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateTheme(_ theme: Marked<Theme>) {
self.theme = theme.payload
self.enclosingScrollView?.backgroundColor = self.theme.background
self.backgroundColor = self.theme.background
self.lastThemeMark = theme.mark
}
private func shouldReloadData(
for state: StateType, themeChanged: Bool = false
) -> Bool {
if self.isShowHidden != state.fileBrowserShowHidden {
return true
}
if themeChanged {
return true
}
if self.showsFileIcon != state.appearance.showsFileIcon {
return true
}
if state.cwd != self.cwd {
return true
}
return false
}
private func handleRemovals(for fileBrowserItem: FileBrowserItem,
new newChildren: [FileBrowserItem]) {
let curChildren = fileBrowserItem.children
let curPreparedChildren = self.prepare(curChildren)
let newPreparedChildren = self.prepare(newChildren)
let indicesToRemove = curPreparedChildren
.enumerated()
.filter { (_, fileBrowserItem) in
newPreparedChildren.contains(fileBrowserItem) == false
}
.map { (idx, _) in idx }
indicesToRemove.forEach { index in
let path = curPreparedChildren[index].url.path
self.cells.removeValue(forKey: path)
self.widths.removeValue(forKey: path)
}
fileBrowserItem.children = curChildren.filter { newChildren.contains($0) }
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
self.removeItems(at: IndexSet(indicesToRemove), inParent: parent)
}
private func handleAdditions(for fileBrowserItem: FileBrowserItem,
new newChildren: [FileBrowserItem]) {
let curChildren = fileBrowserItem.children
let curPreparedChildren = self.prepare(curChildren)
let newPreparedChildren = self.prepare(newChildren)
let indicesToInsert = newPreparedChildren
.enumerated()
.filter { (_, fileBrowserItem) in
curPreparedChildren.contains(fileBrowserItem) == false
}
.map { (idx, _) in idx }
// We don't just take newChildren because NSOutlineView look
// at the pointer equality for preserving the expanded states...
fileBrowserItem.children = newChildren.substituting(elements: curChildren)
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
self.insertItems(at: IndexSet(indicesToInsert), inParent: parent)
}
private func sortedChildren(of url: URL) -> [FileBrowserItem] {
return FileUtils.directDescendants(of: url)
.map(FileBrowserItem.init).sorted {
if ($0.isDir == $1.isDir) {
return $0.url.absoluteString < $1.url.absoluteString
}
return $0.isDir
}
}
private func update(_ fileBrowserItem: FileBrowserItem) {
let url = fileBrowserItem.url
// Sort the array to keep the order.
let newChildren = self.sortedChildren(of: url)
self.beginUpdates()
self.handleRemovals(for: fileBrowserItem, new: newChildren)
self.endUpdates()
self.beginUpdates()
self.handleAdditions(for: fileBrowserItem, new: newChildren)
self.endUpdates()
fileBrowserItem.isChildrenScanned = true
fileBrowserItem.children
.filter { self.isItemExpanded($0) }
.forEach(self.update)
}
private func fileBrowserItem(with url: URL) -> FileBrowserItem? {
if self.cwd == url {
return self.root
}
guard self.cwd.isParent(of: url) else {
return nil
}
let rootPathComps = self.cwd.pathComponents
let pathComps = url.pathComponents
let childPart = pathComps[rootPathComps.count..<pathComps.count]
return childPart
.reduce(self.root) { (resultItem, childName) -> FileBrowserItem? in
guard let parent = resultItem else {
return nil
}
return parent.child(with: parent.url.appendingPathComponent(childName))
}
}
}
// MARK: - NSOutlineViewDataSource
extension FileOutlineViewOld {
private func scanChildrenIfNecessary(_ fileBrowserItem: FileBrowserItem) {
guard fileBrowserItem.isChildrenScanned == false else {
return
}
fileBrowserItem.children = self.sortedChildren(of: fileBrowserItem.url)
fileBrowserItem.isChildrenScanned = true
}
private func prepare(_ children: [FileBrowserItem]) -> [FileBrowserItem] {
return self.isShowHidden ? children : children.filter { !$0.isHidden }
}
func outlineView(_: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil {
self.scanChildrenIfNecessary(self.root)
return self.prepare(self.root.children).count
}
guard let fileBrowserItem = item as? FileBrowserItem else {
return 0
}
if fileBrowserItem.url.isDir {
self.scanChildrenIfNecessary(fileBrowserItem)
return self.prepare(fileBrowserItem.children).count
}
return 0
}
func outlineView(
_: NSOutlineView, child index: Int, ofItem item: Any?
) -> Any {
let level = self.level(forItem: item) + 2
defer { self.adjustColumnWidths() }
if item == nil {
let child = self.prepare(self.root.children)[index]
let cell = self.cell(forItem: child)
self.cells[child.url.path] = cell
self.widths[child.url.path] = self.cellWidth(for: cell, level: level)
return child
}
guard let fileBrowserItem = item as? FileBrowserItem else {
preconditionFailure("Should not happen")
}
let child = self.prepare(fileBrowserItem.children)[index]
let cell = self.cell(forItem: child)
self.cells[child.url.path] = cell
self.widths[child.url.path] = self.cellWidth(for: cell, level: level)
return child
}
private func cell(forItem item: FileBrowserItem) -> ThemedTableCell {
if let existingCell = self.cells[item.url.path] {
return existingCell
}
let cell = ThemedTableCell(withIdentifier: "file-cell-view")
cell.isDir = item.isDir
cell.text = item.url.lastPathComponent
if self.showsFileIcon {
let icon = FileUtils.icon(forUrl: item.url)
cell.image = cell.isHidden
? icon?.tinting(with: NSColor.white.withAlphaComponent(0.4))
: icon
}
return cell
}
func outlineView(_: NSOutlineView, isItemExpandable item: Any) -> Bool {
guard let fileBrowserItem = item as? FileBrowserItem else {
return false
}
return fileBrowserItem.url.isDir
}
@objc(outlineView:objectValueForTableColumn:byItem:)
func outlineView(_: NSOutlineView,
objectValueFor: NSTableColumn?,
byItem item: Any?) -> Any? {
guard let fileBrowserItem = item as? FileBrowserItem else {
return nil
}
return fileBrowserItem
}
private func cellWidth(for cell: NSView?, level: Int) -> CGFloat {
let cellWidth = cell?.intrinsicContentSize.width ?? 0
let indentation = CGFloat(level + 1) * self.indentationPerLevel + 4
return cellWidth + indentation
}
private func adjustColumnWidths() {
guard let column = self.outlineTableColumn else {
return
}
column.minWidth = self.widths.values.max() ?? 100
column.maxWidth = self.widths.values.max() ?? 100
}
}
private class FileBrowserItem: Hashable, Comparable, CustomStringConvertible {
static func ==(left: FileBrowserItem, right: FileBrowserItem) -> Bool {
return left.url == right.url
}
static func <(left: FileBrowserItem, right: FileBrowserItem) -> Bool {
return left.url.lastPathComponent < right.url.lastPathComponent
}
var hashValue: Int {
return self.url.hashValue
}
var description: String {
return self.url.path
}
let url: URL
let isDir: Bool
let isHidden: Bool
var children: [FileBrowserItem] = []
var isChildrenScanned = false
init(_ url: URL) {
self.url = url
// We cache the value here since we often get the value when the file is
// not there, eg when updating because the file gets deleted
// (in self.prepare() function)
self.isHidden = url.isHidden
self.isDir = url.isDir
}
func child(with url: URL) -> FileBrowserItem? {
return self.children.first { $0.url == url }
}
}