feat: many file browser improvements (#195)

* toolbar with many shortcuts + search bar
* drag & drop improvements
* QuickLook feature
This commit is contained in:
Adam McNight
2026-03-12 06:51:31 +01:00
committed by GitHub
parent 11048d6c00
commit 06e12c94a1
4 changed files with 187 additions and 17 deletions

View File

@@ -5,6 +5,9 @@ import Observation
@MainActor
class VPhoneFileBrowserModel {
let control: VPhoneControl
private let quickLookController: VPhoneQuickLookController
/// Tracks an in-flight Quick Look download so it can be cancelled on selection change.
private var quickLookTask: Task<Void, Never>?
var currentPath = "/var/mobile"
var files: [VPhoneRemoteFile] = []
@@ -22,11 +25,13 @@ class VPhoneFileBrowserModel {
transferName != nil
}
/// Navigation stack
/// Navigation stacks
private var pathHistory: [String] = []
private var forwardHistory: [String] = []
init(control: VPhoneControl) {
init(control: VPhoneControl, quickLookController: VPhoneQuickLookController) {
self.control = control
self.quickLookController = quickLookController
}
// MARK: - Computed
@@ -66,6 +71,7 @@ class VPhoneFileBrowserModel {
func navigate(to path: String) {
pathHistory.append(currentPath)
forwardHistory.removeAll()
currentPath = path
selection.removeAll()
Task { await refresh() }
@@ -73,22 +79,31 @@ class VPhoneFileBrowserModel {
func goBack() {
guard let prev = pathHistory.popLast() else { return }
forwardHistory.append(currentPath)
currentPath = prev
selection.removeAll()
Task { await refresh() }
}
func goForward() {
guard let next = forwardHistory.popLast() else { return }
pathHistory.append(currentPath)
currentPath = next
selection.removeAll()
Task { await refresh() }
}
func goToBreadcrumb(_ path: String) {
if path == currentPath { return }
pathHistory.append(currentPath)
forwardHistory.removeAll()
currentPath = path
selection.removeAll()
Task { await refresh() }
}
var canGoBack: Bool {
!pathHistory.isEmpty
}
var canGoBack: Bool { !pathHistory.isEmpty }
var canGoForward: Bool { !forwardHistory.isEmpty }
func openItem(_ file: VPhoneRemoteFile) {
if file.isDirectoryLike {
@@ -96,6 +111,35 @@ class VPhoneFileBrowserModel {
}
}
// MARK: - Quick Look
func quickLookSelected() {
guard let id = selection.first,
let file = filteredFiles.first(where: { $0.id == id }),
!file.isDirectoryLike
else { return }
quickLookTask?.cancel()
quickLookTask = Task { @MainActor in
do {
let data = try await control.downloadFile(path: file.path)
guard !Task.isCancelled else { return }
quickLookController.open(data: data, filename: file.name)
} catch {
guard !Task.isCancelled else { return }
self.error = "Quick Look download failed: \(error)"
}
quickLookTask = nil
}
}
func closeQuickLook() {
quickLookTask?.cancel()
quickLookTask = nil
quickLookController.close()
}
// MARK: - Refresh
func refresh() async {

View File

@@ -83,6 +83,7 @@ struct VPhoneFileBrowserView: View {
} rows: {
ForEach(model.filteredFiles) { file in
TableRow(file)
.draggable(FileDragItem(file: file, control: model.control))
}
}
.contextMenu(forSelectionType: VPhoneRemoteFile.ID.self) { ids in
@@ -90,6 +91,13 @@ struct VPhoneFileBrowserView: View {
} primaryAction: { ids in
primaryAction(for: ids)
}
.onKeyPress(.space) {
model.quickLookSelected()
return .handled
}
.onChange(of: model.selection) {
model.closeQuickLook()
}
}
// MARK: - Control Bar
@@ -172,13 +180,14 @@ struct VPhoneFileBrowserView: View {
.disabled(!model.canGoBack)
.keyboardShortcut(.leftArrow, modifiers: .command)
}
ToolbarItem {
ToolbarItem(placement: .navigation) {
Button {
Task { await model.refresh() }
model.goForward()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
Label("Forward", systemImage: "chevron.right")
}
.keyboardShortcut("r", modifiers: .command)
.disabled(!model.canGoForward)
.keyboardShortcut(.rightArrow, modifiers: .command)
}
ToolbarItem {
Button {
@@ -212,6 +221,14 @@ struct VPhoneFileBrowserView: View {
}
.disabled(model.selection.isEmpty)
}
ToolbarItem {
Button {
Task { await model.refresh() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.keyboardShortcut("r", modifiers: .command)
}
}
// MARK: - Context Menu
@@ -345,3 +362,25 @@ struct VPhoneFileBrowserView: View {
ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
}
// MARK: - Drag out
private struct FileDragItem: Transferable {
let file: VPhoneRemoteFile
let control: VPhoneControl
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(exportedContentType: .data) { item in
guard !item.file.isDirectoryLike else {
throw CocoaError(.fileNoSuchFile)
}
let data = try await item.control.downloadFile(path: item.file.path)
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempURL = tempDir.appendingPathComponent(item.file.name)
try data.write(to: tempURL)
return SentTransferredFile(tempURL)
}
}
}

View File

@@ -5,19 +5,19 @@ import SwiftUI
class VPhoneFileWindowController {
private var window: NSWindow?
private var model: VPhoneFileBrowserModel?
private let quickLookController = VPhoneQuickLookController()
func showWindow(control: VPhoneControl) {
// Reuse existing window
if let window {
window.makeKeyAndOrderFront(nil)
return
}
let model = VPhoneFileBrowserModel(control: control)
let model = VPhoneFileBrowserModel(control: control, quickLookController: quickLookController)
self.model = model
let view = VPhoneFileBrowserView(model: model)
let hostingView = NSHostingView(rootView: view)
let hostingController = NSHostingController(rootView: view)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 500),
@@ -27,16 +27,17 @@ class VPhoneFileWindowController {
)
window.title = "Files"
window.subtitle = "vphone"
window.contentView = hostingView
window.contentViewController = hostingController
window.contentMinSize = NSSize(width: 500, height: 300)
window.setContentSize(NSSize(width: 700, height: 500))
window.center()
window.toolbarStyle = .unified
window.isReleasedWhenClosed = false
// Add toolbar so the unified title bar shows
let toolbar = NSToolbar(identifier: "vphone-files-toolbar")
toolbar.displayMode = .iconOnly
window.toolbar = toolbar
// Insert quickLookController at the window level so AppKit finds it
// when walking the responder chain for QLPreviewPanel panel control.
quickLookController.nextResponder = window.nextResponder
window.nextResponder = quickLookController
window.makeKeyAndOrderFront(nil)
self.window = window
@@ -47,6 +48,7 @@ class VPhoneFileWindowController {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.model?.closeQuickLook()
self?.window = nil
self?.model = nil
}

View File

@@ -0,0 +1,85 @@
import AppKit
@preconcurrency import Quartz
@MainActor
final class VPhoneQuickLookController: NSResponder, QLPreviewPanelDataSource {
private var tempDir: URL?
private(set) var previewURL: URL?
// MARK: - Public API
func open(data: Data, filename: String) {
cleanupTempFiles()
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
print("[ql] failed to create temp dir: \(error)")
return
}
let fileURL = dir.appendingPathComponent(filename)
do {
try data.write(to: fileURL)
} catch {
print("[ql] failed to write temp file: \(error)")
try? FileManager.default.removeItem(at: dir)
return
}
tempDir = dir
previewURL = fileURL
guard let panel = QLPreviewPanel.shared() else { return }
panel.dataSource = self
panel.reloadData()
panel.makeKeyAndOrderFront(nil)
}
func close() {
guard previewURL != nil else { return }
QLPreviewPanel.shared()?.orderOut(nil)
// cleanupTempFiles() is called by endPreviewPanelControl after orderOut.
}
// MARK: - QLPreviewPanelDataSource
// AppKit calls these on the main thread.
nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
MainActor.assumeIsolated { previewURL != nil ? 1 : 0 }
}
nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> any QLPreviewItem {
MainActor.assumeIsolated { (previewURL ?? URL(fileURLWithPath: "/dev/null")) as NSURL }
}
// MARK: - QLPreviewPanelController
// AppKit calls these when walking the responder chain.
nonisolated override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool {
// Called on main thread; synchronous return required.
MainActor.assumeIsolated { previewURL != nil }
}
nonisolated override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) {
// `open()` already sets panel.dataSource synchronously before showing the panel.
// Nothing to do here; the conformance method must exist for QLPreviewPanelController.
}
nonisolated override func endPreviewPanelControl(_ panel: QLPreviewPanel!) {
Task { @MainActor in
cleanupTempFiles()
}
}
// MARK: - Private
private func cleanupTempFiles() {
previewURL = nil
if let dir = tempDir {
try? FileManager.default.removeItem(at: dir)
tempDir = nil
}
}
}