1
1
mirror of https://github.com/qvacua/vimr.git synced 2024-12-26 23:36:08 +03:00
vimr/VimR/FileOutlineView.swift

517 lines
14 KiB
Swift
Raw Normal View History

2016-10-03 16:03:18 +03:00
/**
* Tae Won Ha - http://taewon.de - @hataewon
* See LICENSE
*/
import Cocoa
2016-11-25 17:25:55 +03:00
import PureLayout
2016-10-03 16:03:18 +03:00
import RxSwift
class FileOutlineView: NSOutlineView,
UiComponent,
NSOutlineViewDataSource,
NSOutlineViewDelegate {
typealias StateType = MainWindow.State
required init(source: Observable<StateType>, emitter: ActionEmitter, state: StateType) {
2017-04-26 20:40:42 +03:00
self.emit = emitter.typedEmit()
self.uuid = state.uuid
2017-04-22 16:56:13 +03:00
self.root = FileBrowserItem(state.cwd)
self.isShowHidden = state.fileBrowserShowHidden
super.init(frame: .zero)
NSOutlineView.configure(toStandard: self)
self.dataSource = self
self.delegate = self
2016-11-12 18:42:10 +03:00
guard Bundle.main.loadNibNamed("FileBrowserMenu", owner: self, topLevelObjects: nil) else {
NSLog("WARN: FileBrowserMenu.xib could not be loaded")
return
}
2017-05-19 13:42:16 +03:00
// 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... -_-
2017-02-26 12:12:41 +03:00
self.menu?.items.forEach { $0.target = self }
2016-11-12 18:42:10 +03:00
self.doubleAction = #selector(FileOutlineView.doubleClickAction)
source
2017-05-16 23:54:49 +03:00
.filter { !self.reloadData(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
2017-05-14 13:06:21 +03:00
self.update(state.lastFileSystemUpdate.payload)
})
.disposed(by: self.disposeBag)
source
.observeOn(MainScheduler.instance)
.subscribe(onNext: { state in
2017-03-31 20:32:01 +03:00
if state.viewToBeFocused != nil, case .fileBrowser = state.viewToBeFocused! {
2017-02-25 00:47:32 +03:00
self.beFirstResponder()
}
2017-05-16 23:54:49 +03:00
guard self.reloadData(for: state) else {
2017-02-24 01:58:53 +03:00
return
}
2017-05-16 23:54:49 +03:00
self.isShowHidden = state.fileBrowserShowHidden
self.lastFileSystemUpdateMark = state.lastFileSystemUpdate.mark
self.root = FileBrowserItem(state.cwd)
self.reloadData()
})
.disposed(by: self.disposeBag)
}
2016-11-25 17:25:55 +03:00
func select(_ url: URL) {
var stack = [self.root]
2016-12-11 21:47:17 +03:00
while let item = stack.popLast() {
2017-05-19 13:57:04 +03:00
self.expandItem(item)
2016-12-11 21:47:17 +03:00
if item.url.isDirectParent(of: url) {
if let targetItem = item.children.first(where: { $0.url == url }) {
2017-05-19 13:57:04 +03:00
let targetRow = self.row(forItem: targetItem)
self.selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false)
self.scrollRowToVisible(targetRow)
2016-12-11 21:47:17 +03:00
}
2017-05-19 13:57:04 +03:00
2016-12-11 21:47:17 +03:00
break
}
stack.append(contentsOf: item.children.filter { $0.url.isParent(of: url) })
2016-12-11 21:47:17 +03:00
}
}
2017-04-22 16:56:13 +03:00
fileprivate let emit: (UuidAction<FileBrowser.Action>) -> Void
fileprivate let disposeBag = DisposeBag()
fileprivate let uuid: String
fileprivate var lastFileSystemUpdateMark = Token()
fileprivate var cwd: URL {
return self.root.url
}
2017-02-26 13:00:52 +03:00
fileprivate var isShowHidden: Bool
fileprivate var root: FileBrowserItem
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
2017-05-16 23:54:49 +03:00
fileprivate func reloadData(for state: StateType) -> Bool {
if self.isShowHidden != state.fileBrowserShowHidden {
return true
}
if state.cwd != self.cwd {
return true
}
return false
}
fileprivate func update(_ url: URL) {
guard let fileBrowserItem = self.fileBrowserItem(with: url) else {
return
}
self.update(fileBrowserItem)
}
2017-05-19 13:42:16 +03:00
fileprivate func handleRemovals(for fileBrowserItem: FileBrowserItem,
new newChildren: [FileBrowserItem]) {
let curChildren = fileBrowserItem.children
2016-11-26 17:13:21 +03:00
let curPreparedChildren = self.prepare(curChildren)
let newPreparedChildren = self.prepare(newChildren)
2017-05-19 11:25:50 +03:00
let indicesToRemove = curPreparedChildren
.enumerated()
.filter { (_, fileBrowserItem) in newPreparedChildren.contains(fileBrowserItem) == false }
.map { (idx, _) in idx }
2016-11-26 19:59:55 +03:00
logger.debug("\(fileBrowserItem): \(curPreparedChildren) vs. \(indicesToRemove)")
2016-11-26 17:13:21 +03:00
fileBrowserItem.children = curChildren.filter { newChildren.contains($0) }
2016-11-26 19:59:55 +03:00
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
2017-05-19 11:25:50 +03:00
self.removeItems(at: IndexSet(indicesToRemove), inParent: parent)
2016-11-26 19:59:55 +03:00
}
2017-05-19 13:42:16 +03:00
fileprivate func handleAdditions(for fileBrowserItem: FileBrowserItem,
new newChildren: [FileBrowserItem]) {
let curChildren = fileBrowserItem.children
2016-11-26 19:59:55 +03:00
let curPreparedChildren = self.prepare(curChildren)
let newPreparedChildren = self.prepare(newChildren)
2016-11-26 20:05:35 +03:00
let indicesToInsert = newPreparedChildren
.enumerated()
2017-05-19 11:25:50 +03:00
.filter { (_, fileBrowserItem) in curPreparedChildren.contains(fileBrowserItem) == false }
.map { (idx, _) in idx }
2016-11-26 19:59:55 +03:00
logger.debug("\(fileBrowserItem): \(curPreparedChildren) vs. \(indicesToInsert)")
// We don't just take newChildren because NSOutlineView look at the pointer equality for
// preserving the expanded states...
fileBrowserItem.children = newChildren.substituting(elements: curChildren)
2016-11-26 20:05:35 +03:00
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
self.insertItems(at: IndexSet(indicesToInsert), inParent: parent)
2016-11-26 19:59:55 +03:00
}
fileprivate func sortedChildren(of url: URL) -> [FileBrowserItem] {
return FileUtils.directDescendants(of: url).map(FileBrowserItem.init).sorted()
}
2016-11-26 19:59:55 +03:00
fileprivate func update(_ fileBrowserItem: FileBrowserItem) {
let url = fileBrowserItem.url
2016-11-26 19:59:55 +03:00
// Sort the array to keep the order.
let newChildren = self.sortedChildren(of: url)
2016-11-26 19:59:55 +03:00
self.beginUpdates()
2016-11-26 19:59:55 +03:00
self.handleRemovals(for: fileBrowserItem, new: newChildren)
self.endUpdates()
self.beginUpdates()
2016-11-26 19:59:55 +03:00
self.handleAdditions(for: fileBrowserItem, new: newChildren)
self.endUpdates()
2017-05-19 13:32:35 +03:00
fileBrowserItem.isChildrenScanned = true
fileBrowserItem.children.filter { self.isItemExpanded($0) }.forEach(self.update)
}
2016-11-25 17:25:55 +03:00
fileprivate func fileBrowserItem(with url: URL) -> FileBrowserItem? {
2016-11-26 12:34:52 +03:00
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 FileOutlineView {
2017-05-19 13:57:04 +03:00
fileprivate func scanChildrenIfNecessary(_ fileBrowserItem: FileBrowserItem) {
2017-05-19 13:32:35 +03:00
guard fileBrowserItem.isChildrenScanned == false else {
return
}
fileBrowserItem.children = self.sortedChildren(of: fileBrowserItem.url)
fileBrowserItem.isChildrenScanned = true
}
2016-11-26 12:34:52 +03:00
fileprivate func prepare(_ children: [FileBrowserItem]) -> [FileBrowserItem] {
return self.isShowHidden ? children : children.filter { !$0.isHidden }
2016-11-26 12:34:52 +03:00
}
func outlineView(_: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil {
2017-05-19 13:32:35 +03:00
self.scanChildrenIfNecessary(self.root)
2016-11-26 12:34:52 +03:00
return self.prepare(self.root.children).count
}
guard let fileBrowserItem = item as? FileBrowserItem else {
return 0
}
if fileBrowserItem.url.isDir {
2017-05-19 13:32:35 +03:00
self.scanChildrenIfNecessary(fileBrowserItem)
2016-11-26 12:34:52 +03:00
return self.prepare(fileBrowserItem.children).count
}
return 0
}
func outlineView(_: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
let level = self.level(forItem: item)
if item == nil {
self.adjustColumnWidth(for: self.root.children, outlineViewLevel: level)
2016-11-26 12:34:52 +03:00
return self.prepare(self.root.children)[index]
}
guard let fileBrowserItem = item as? FileBrowserItem else {
preconditionFailure("Should not happen")
}
self.adjustColumnWidth(for: fileBrowserItem.children, outlineViewLevel: level)
2016-11-26 12:34:52 +03:00
return self.prepare(fileBrowserItem.children)[index]
}
2016-11-26 12:34:52 +03:00
func outlineView(_: NSOutlineView, isItemExpandable item: Any) -> Bool {
guard let fileBrowserItem = item as? FileBrowserItem else {
return false
}
return fileBrowserItem.url.isDir
}
2016-11-26 12:34:52 +03:00
@objc(outlineView: objectValueForTableColumn:byItem:)
func outlineView(_: NSOutlineView, objectValueFor: NSTableColumn?, byItem item: Any?) -> Any? {
guard let fileBrowserItem = item as? FileBrowserItem else {
return nil
}
return fileBrowserItem
2016-10-03 16:03:18 +03:00
}
fileprivate func adjustColumnWidth() {
let column = self.outlineTableColumn!
let rows = (0..<self.numberOfRows).map {
(item: self.item(atRow: $0) as! FileBrowserItem?, level: self.level(forRow: $0))
}
let cellWidth = rows.concurrentChunkMap(20) {
2017-05-11 22:11:34 +03:00
guard let fileBrowserItem = $0.item else {
return 0
}
2017-05-19 13:42:16 +03:00
// + 2 just to have a buffer... -_-
return ImageAndTextTableCell.width(with: fileBrowserItem.url.lastPathComponent)
2017-05-19 13:42:16 +03:00
+ (CGFloat($0.level + 2) * (self.indentationPerLevel + 2))
2017-05-11 22:11:34 +03:00
}.max() ?? column.width
guard column.minWidth != cellWidth else {
return
}
column.minWidth = cellWidth
column.maxWidth = cellWidth
}
fileprivate func adjustColumnWidth(for items: [FileBrowserItem], outlineViewLevel level: Int) {
let column = self.outlineTableColumn!
// It seems like that caching the widths is slower due to thread-safeness of NSCache...
let cellWidth = items.concurrentChunkMap(20) {
let result = ImageAndTextTableCell.width(with: $0.url.lastPathComponent)
2017-05-11 22:11:34 +03:00
return result
}.max() ?? column.width
2016-11-25 17:25:55 +03:00
2017-05-19 13:42:16 +03:00
// + 2 just to have a buffer... -_-
let width = cellWidth + (CGFloat(level + 2) * (self.indentationPerLevel + 2))
guard column.minWidth < width else {
2016-11-25 17:25:55 +03:00
return
}
column.minWidth = width
column.maxWidth = width
2016-11-25 17:25:55 +03:00
}
}
2016-11-25 17:25:55 +03:00
// MARK: - NSOutlineViewDelegate
extension FileOutlineView {
2016-11-25 17:25:55 +03:00
2016-11-26 12:34:52 +03:00
@objc(outlineView: viewForTableColumn:item:)
func outlineView(_: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let fileBrowserItem = item as? FileBrowserItem else {
return nil
}
2017-05-19 13:42:16 +03:00
let cachedCell =
(self.make(withIdentifier: "file-view-row", owner: self) as? ImageAndTextTableCell)?.reset()
let cell = cachedCell ?? ImageAndTextTableCell(withIdentifier: "file-view-row")
cell.text = fileBrowserItem.url.lastPathComponent
let icon = FileUtils.icon(forUrl: fileBrowserItem.url)
cell.image = fileBrowserItem.isHidden
2017-05-19 13:42:16 +03:00
? icon?.tinting(with: NSColor.white.withAlphaComponent(0.4))
: icon
return cell
}
func outlineView(_: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
return 20
}
2016-11-25 15:19:41 +03:00
func outlineViewItemDidCollapse(_ notification: Notification) {
self.adjustColumnWidth()
}
}
2016-11-12 18:42:10 +03:00
// MARK: - Actions
extension FileOutlineView {
@IBAction func doubleClickAction(_: Any?) {
guard let item = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return
}
if item.url.isDir {
2016-11-12 18:42:10 +03:00
self.toggle(item: item)
} else {
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .default))
2017-02-24 12:51:24 +03:00
)
2016-11-12 18:42:10 +03:00
}
}
@IBAction func openInNewTab(_: Any?) {
guard let item = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return
}
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .newTab))
2017-02-24 12:51:24 +03:00
)
2016-11-12 18:42:10 +03:00
}
@IBAction func openInCurrentTab(_: Any?) {
guard let item = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return
}
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .currentTab))
2017-02-24 12:51:24 +03:00
)
2016-11-12 18:42:10 +03:00
}
@IBAction func openInHorizontalSplit(_: Any?) {
guard let item = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return
}
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .horizontalSplit))
2017-02-24 12:51:24 +03:00
)
2016-11-12 18:42:10 +03:00
}
@IBAction func openInVerticalSplit(_: Any?) {
guard let item = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return
}
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .verticalSplit))
2017-02-24 12:51:24 +03:00
)
2016-11-12 18:42:10 +03:00
}
@IBAction func setAsWorkingDirectory(_: Any?) {
guard let item = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return
}
guard item.url.isDir else {
2016-11-12 18:42:10 +03:00
return
}
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .setAsWorkingDirectory(item.url))
2017-02-24 12:51:24 +03:00
)
2016-11-12 18:42:10 +03:00
}
}
// MARK: - NSUserInterfaceValidations
extension FileOutlineView {
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
guard let clickedItem = self.clickedItem as? FileBrowserItem else {
2016-11-12 18:42:10 +03:00
return true
}
if item.action == #selector(setAsWorkingDirectory(_:)) {
return clickedItem.url.isDir
2016-11-12 18:42:10 +03:00
}
return true
}
}
// MARK: - NSView
extension FileOutlineView {
2016-10-03 16:03:18 +03:00
override func keyDown(with event: NSEvent) {
guard let char = event.charactersIgnoringModifiers?.characters.first else {
super.keyDown(with: event)
return
}
guard let item = self.selectedItem as? FileBrowserItem else {
2016-10-03 16:03:18 +03:00
super.keyDown(with: event)
return
}
switch char {
case " ", "\r": // Why "\r" and not "\n"?
if item.url.isDir || item.url.isPackage {
2016-10-03 16:03:18 +03:00
self.toggle(item: item)
} else {
2017-04-22 16:56:13 +03:00
self.emit(
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .newTab))
2017-02-24 12:51:24 +03:00
)
2016-10-03 16:03:18 +03:00
}
default:
super.keyDown(with: event)
}
}
}
fileprivate 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 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
}
func child(with url: URL) -> FileBrowserItem? {
return self.children.first { $0.url == url }
}
}