mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 04:59:05 +08:00
feat: many file browser improvements (#195)
* toolbar with many shortcuts + search bar * drag & drop improvements * QuickLook feature
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
85
sources/vphone-cli/VPhoneQuickLookController.swift
Normal file
85
sources/vphone-cli/VPhoneQuickLookController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user