mirror of
https://github.com/qvacua/vimr.git
synced 2024-11-25 06:06:21 +03:00
532 lines
15 KiB
Swift
532 lines
15 KiB
Swift
/**
|
|
* Tae Won Ha - http://taewon.de - @hataewon
|
|
* See LICENSE
|
|
*/
|
|
|
|
import Cocoa
|
|
import SwiftNeoVim
|
|
import PureLayout
|
|
import RxSwift
|
|
|
|
class FileOutlineView: NSOutlineView,
|
|
UiComponent,
|
|
NSOutlineViewDataSource,
|
|
NSOutlineViewDelegate,
|
|
ThemedView {
|
|
|
|
typealias StateType = MainWindow.State
|
|
|
|
fileprivate(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("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(FileOutlineView.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.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) })
|
|
}
|
|
}
|
|
|
|
fileprivate let emit: (UuidAction<FileBrowser.Action>) -> Void
|
|
fileprivate let disposeBag = DisposeBag()
|
|
|
|
fileprivate let uuid: String
|
|
fileprivate var lastFileSystemUpdateMark = Token()
|
|
fileprivate var usesTheme: Bool
|
|
fileprivate var lastThemeMark = Token()
|
|
fileprivate var showsFileIcon: Bool
|
|
|
|
fileprivate var cwd: URL {
|
|
return self.root.url
|
|
}
|
|
fileprivate var isShowHidden: Bool
|
|
|
|
fileprivate var root: FileBrowserItem
|
|
|
|
fileprivate var widths = [String: CGFloat]()
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
fileprivate func updateTheme(_ theme: Marked<Theme>) {
|
|
self.theme = theme.payload
|
|
self.enclosingScrollView?.backgroundColor = self.theme.background
|
|
self.backgroundColor = self.theme.background
|
|
self.lastThemeMark = theme.mark
|
|
}
|
|
|
|
fileprivate 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
|
|
}
|
|
|
|
fileprivate 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 { self.widths.removeValue(forKey: curPreparedChildren[$0].url.path) }
|
|
|
|
fileLog.debug("\(fileBrowserItem): \(curPreparedChildren) vs. \(indicesToRemove)")
|
|
|
|
fileBrowserItem.children = curChildren.filter { newChildren.contains($0) }
|
|
|
|
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
|
|
self.removeItems(at: IndexSet(indicesToRemove), inParent: parent)
|
|
}
|
|
|
|
fileprivate 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 }
|
|
|
|
fileLog.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)
|
|
|
|
let parent = fileBrowserItem == self.root ? nil : fileBrowserItem
|
|
self.insertItems(at: IndexSet(indicesToInsert), inParent: parent)
|
|
}
|
|
|
|
fileprivate func sortedChildren(of url: URL) -> [FileBrowserItem] {
|
|
return FileUtils.directDescendants(of: url).map(FileBrowserItem.init).sorted()
|
|
}
|
|
|
|
fileprivate 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)
|
|
}
|
|
|
|
fileprivate 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 FileOutlineView {
|
|
|
|
fileprivate func scanChildrenIfNecessary(_ fileBrowserItem: FileBrowserItem) {
|
|
guard fileBrowserItem.isChildrenScanned == false else {
|
|
return
|
|
}
|
|
|
|
fileBrowserItem.children = self.sortedChildren(of: fileBrowserItem.url)
|
|
fileBrowserItem.isChildrenScanned = true
|
|
}
|
|
|
|
fileprivate 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 {
|
|
if item == nil {
|
|
return self.prepare(self.root.children)[index]
|
|
}
|
|
|
|
guard let fileBrowserItem = item as? FileBrowserItem else {
|
|
preconditionFailure("Should not happen")
|
|
}
|
|
|
|
return self.prepare(fileBrowserItem.children)[index]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fileprivate 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
|
|
}
|
|
|
|
fileprivate func adjustColumnWidths() {
|
|
guard let column = self.outlineTableColumn else {
|
|
return
|
|
}
|
|
|
|
column.minWidth = self.widths.values.max() ?? 100
|
|
column.maxWidth = self.widths.values.max() ?? 100
|
|
}
|
|
}
|
|
|
|
// MARK: - NSOutlineViewDelegate
|
|
extension FileOutlineView {
|
|
|
|
func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
|
|
return self.make(withIdentifier: "file-row-view", owner: self) as? ThemedTableRow
|
|
?? ThemedTableRow(withIdentifier: "file-row-view", themedView: self)
|
|
}
|
|
|
|
func outlineView(_: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
|
|
guard let fileBrowserItem = item as? FileBrowserItem else {
|
|
return nil
|
|
}
|
|
|
|
let cell = (self.make(withIdentifier: "file-cell-view", owner: self) as? ThemedTableCell)?.reset()
|
|
?? ThemedTableCell(withIdentifier: "file-cell-view")
|
|
|
|
cell.isDir = fileBrowserItem.isDir
|
|
cell.text = fileBrowserItem.url.lastPathComponent
|
|
|
|
guard self.showsFileIcon else {
|
|
return cell
|
|
}
|
|
|
|
let icon = FileUtils.icon(forUrl: fileBrowserItem.url)
|
|
cell.image = fileBrowserItem.isHidden ? icon?.tinting(with: NSColor.white.withAlphaComponent(0.4)) : icon
|
|
|
|
self.widths[fileBrowserItem.url.path] = self.cellWidth(for: cell, level: self.level(forItem: item))
|
|
self.adjustColumnWidths()
|
|
|
|
return cell
|
|
}
|
|
|
|
func outlineView(_: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
|
|
return 20
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
extension FileOutlineView {
|
|
|
|
@IBAction func doubleClickAction(_: Any?) {
|
|
guard let item = self.clickedItem as? FileBrowserItem else {
|
|
return
|
|
}
|
|
|
|
if item.url.isDir {
|
|
self.toggle(item: item)
|
|
} else {
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .default))
|
|
)
|
|
}
|
|
}
|
|
|
|
@IBAction func openInNewTab(_: Any?) {
|
|
guard let item = self.clickedItem as? FileBrowserItem else {
|
|
return
|
|
}
|
|
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .newTab))
|
|
)
|
|
}
|
|
|
|
@IBAction func openInCurrentTab(_: Any?) {
|
|
guard let item = self.clickedItem as? FileBrowserItem else {
|
|
return
|
|
}
|
|
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .currentTab))
|
|
)
|
|
}
|
|
|
|
@IBAction func openInHorizontalSplit(_: Any?) {
|
|
guard let item = self.clickedItem as? FileBrowserItem else {
|
|
return
|
|
}
|
|
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .horizontalSplit))
|
|
)
|
|
}
|
|
|
|
@IBAction func openInVerticalSplit(_: Any?) {
|
|
guard let item = self.clickedItem as? FileBrowserItem else {
|
|
return
|
|
}
|
|
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .verticalSplit))
|
|
)
|
|
}
|
|
|
|
@IBAction func setAsWorkingDirectory(_: Any?) {
|
|
guard let item = self.clickedItem as? FileBrowserItem else {
|
|
return
|
|
}
|
|
|
|
guard item.url.isDir else {
|
|
return
|
|
}
|
|
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .setAsWorkingDirectory(item.url))
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - NSUserInterfaceValidations
|
|
extension FileOutlineView {
|
|
|
|
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
|
|
guard let clickedItem = self.clickedItem as? FileBrowserItem else {
|
|
return true
|
|
}
|
|
|
|
if item.action == #selector(setAsWorkingDirectory(_:)) {
|
|
return clickedItem.url.isDir
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - NSView
|
|
extension FileOutlineView {
|
|
|
|
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 {
|
|
super.keyDown(with: event)
|
|
return
|
|
}
|
|
|
|
switch char {
|
|
case " ", "\r": // Why "\r" and not "\n"?
|
|
if item.url.isDir || item.url.isPackage {
|
|
self.toggle(item: item)
|
|
} else {
|
|
self.emit(
|
|
UuidAction(uuid: self.uuid, action: .open(url: item.url, mode: .newTab))
|
|
)
|
|
}
|
|
|
|
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 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 }
|
|
}
|
|
}
|