Files

578 lines
22 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
// MARK: - File manager
//
// Full-featured within what the sandbox actually permits: navigate anywhere readable,
// jump by typing a path, bookmark locations, and perform the standard mutations with a
// real write probe behind every claim. Nothing here pretends to reach past L1.
// MARK: Clipboard for copy / move
final class FileClipboard: ObservableObject {
enum Mode { case none, copy, move }
@Published private(set) var mode: Mode = .none
@Published private(set) var paths: [String] = []
func stage(_ paths: [String], mode: Mode) {
self.paths = paths
self.mode = mode
}
func clear() {
paths = []
mode = .none
}
var isEmpty: Bool { paths.isEmpty }
}
// MARK: Bookmarks
final class Bookmarks: ObservableObject {
@Published private(set) var items: [String] = []
private let key = "cinder27.bookmarks"
init() {
items = UserDefaults.standard.stringArray(forKey: key) ?? []
if items.isEmpty {
items = [
Container.documents.path,
Container.appSupport.path,
Container.work.path,
"/System/Library/Frameworks",
"/usr/lib",
"/private/etc",
"/tmp",
"/",
]
}
}
func add(_ path: String) {
guard !items.contains(path) else { return }
items.append(path)
persist()
}
func remove(_ path: String) {
items.removeAll { $0 == path }
persist()
}
private func persist() {
UserDefaults.standard.set(items, forKey: key)
}
}
// MARK: - The pane
struct FilesPane: View {
@EnvironmentObject private var browser: FileBrowser
@EnvironmentObject private var clipboard: FileClipboard
@EnvironmentObject private var bookmarks: Bookmarks
@State private var jumpShown = false
@State private var jumpPath = ""
@State private var newFolderShown = false
@State private var newFolderName = ""
@State private var renameTarget: FileItem?
@State private var renameName = ""
@State private var preview: FileItem?
@State private var info: FileItem?
@State private var status: String?
@State private var confirmDelete: FileItem?
@State private var searchText = ""
@State private var selecting = false
@State private var selection = Set<String>()
@State private var sortMode = 0
private var visible: [FileItem] {
var list = browser.items
if !searchText.isEmpty {
list = list.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
switch sortMode {
case 1: list.sort { $0.size > $1.size }
case 2: list.sort { ($0.modified ?? .distantPast) > ($1.modified ?? .distantPast) }
case 3: list.sort { $0.name.localizedStandardCompare($1.name) == .orderedDescending }
default: break
}
return list
}
var body: some View {
VStack(spacing: 0) {
bookmarkBar
pathBar
searchBar
if let status = status {
HStack(spacing: 6) {
Image(systemName: status.hasPrefix("✓") ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text(status).font(T.monoSm).lineLimit(2)
Spacer()
Button { self.status = nil } label: {
Image(systemName: "xmark").font(.system(size: 9, weight: .bold))
}
}
.foregroundColor(status.hasPrefix("✓") ? T.good : T.warn)
.padding(.horizontal, 16).padding(.bottom, 6)
}
if let error = browser.error {
Text(error).font(T.monoSm).foregroundColor(T.bad)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 6)
.fixedSize(horizontal: false, vertical: true)
}
if !clipboard.isEmpty {
clipboardBar
}
List {
ForEach(visible) { item in
row(item)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable { browser.reload() }
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Menu {
Button { jumpShown = true } label: { Label(t("files.jump"), systemImage: "arrow.right.to.line") }
Button { newFolderShown = true } label: { Label(t("files.newFolder"), systemImage: "folder.badge.plus") }
Divider()
Button { bookmarks.add(browser.path); status = tl("✓ 已加入书签") } label: {
Label(t("files.bookmarks"), systemImage: "bookmark")
}
Divider()
Picker("", selection: $sortMode) {
Text(t("files.name")).tag(0)
Text("Size").tag(1)
Text("Date").tag(2)
Text("Z→A").tag(3)
}
} label: {
Image(systemName: "ellipsis.circle")
}.tint(T.ember)
}
}
.sheet(isPresented: $jumpShown) { jumpSheet }
.sheet(item: $preview) { item in
FilePreviewView(item: item).environmentObject(browser)
}
.sheet(item: $info) { item in
FileInfoSheet(item: item, browser: browser)
}
.alert(t("files.newFolder"), isPresented: $newFolderShown) {
TextField(t("files.name"), text: $newFolderName)
Button(t("common.ok")) {
if let err = browser.makeDirectory(newFolderName) {
status = err
} else {
status = tl("✓ 已创建 ") + "\(newFolderName)"
}
newFolderName = ""
}
Button(t("files.cancel"), role: .cancel) { newFolderName = "" }
}
.alert(t("files.rename"), isPresented: Binding(
get: { renameTarget != nil },
set: { if !$0 { renameTarget = nil } })) {
TextField(t("files.name"), text: $renameName)
Button(t("common.ok")) {
if let target = renameTarget {
if let err = browser.rename(target, to: renameName) {
status = err
} else {
status = "✓ \(t("files.renamed")) → \(renameName)"
}
}
renameTarget = nil
}
Button(t("files.cancel"), role: .cancel) { renameTarget = nil }
}
.alert("\(t("files.delete"))?", isPresented: Binding(
get: { confirmDelete != nil },
set: { if !$0 { confirmDelete = nil } })) {
Button(t("files.delete"), role: .destructive) {
if let target = confirmDelete {
if let err = browser.delete(target) {
status = err
} else {
status = "✓ \(t("files.deleted")) \(target.name)"
}
}
confirmDelete = nil
}
Button(t("files.cancel"), role: .cancel) { confirmDelete = nil }
} message: {
Text(confirmDelete?.path ?? "")
}
}
// MARK: Bars
private var bookmarkBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(bookmarks.items, id: \.self) { path in
Button {
browser.chdir(path)
} label: {
Pill(text: (path as NSString).lastPathComponent.isEmpty ? "/" : (path as NSString).lastPathComponent,
color: browser.path == path ? T.ember : .white.opacity(0.45))
}
.contextMenu {
Button(t("files.delete"), role: .destructive) { bookmarks.remove(path) }
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
private var pathBar: some View {
HStack(spacing: 8) {
Button { browser.up() } label: {
Image(systemName: "arrow.up").font(.system(size: 12, weight: .bold))
}.tint(T.ember)
Button {
jumpPath = browser.path
jumpShown = true
} label: {
HStack(spacing: 4) {
Text(browser.path)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
.truncationMode(.head)
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
.foregroundColor(.white.opacity(0.3))
}
}
.buttonStyle(.plain)
Spacer()
if browser.writable {
Pill(text: t("files.writable"), color: T.good)
} else {
Pill(text: t("files.readonly"), color: T.warn)
}
Button { browser.reload() } label: {
Image(systemName: "arrow.clockwise").font(.system(size: 12, weight: .bold))
}.tint(T.ember)
}
.padding(.horizontal, 16)
.padding(.bottom, 6)
}
private var searchBar: some View {
HStack(spacing: 7) {
Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundColor(.white.opacity(0.3))
TextField(t("files.name"), text: $searchText)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
}.tint(.white.opacity(0.3))
}
Text("\(visible.count)").font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
.padding(.horizontal, 10).padding(.vertical, 7)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panel))
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private var clipboardBar: some View {
HStack(spacing: 8) {
Image(systemName: clipboard.mode == .copy ? "doc.on.doc" : "arrow.right.doc.on.clipboard")
.font(.system(size: 11))
.foregroundColor(T.info)
Text("\(clipboard.paths.count) · \(clipboard.mode == .copy ? t("files.copy") : t("files.move"))")
.font(T.monoSm).foregroundColor(.white.opacity(0.6))
Spacer()
Button(t("files.paste")) { paste() }
.font(.system(size: 11, weight: .semibold)).tint(T.ember)
Button(t("files.cancel")) { clipboard.clear() }
.font(.system(size: 11)).tint(.white.opacity(0.4))
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 9).fill(T.info.opacity(0.1)))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(T.info.opacity(0.3), lineWidth: 1))
.padding(.horizontal, 16).padding(.bottom, 8)
}
// MARK: Row
private func row(_ item: FileItem) -> some View {
Button {
if item.isDirectory { browser.enter(item) } else { preview = item }
} label: {
HStack(spacing: 10) {
Image(systemName: item.icon)
.font(.system(size: 13))
.foregroundColor(item.isDirectory ? T.ember : .white.opacity(0.4))
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(T.body)
.foregroundColor(.white.opacity(0.86))
.lineLimit(1)
HStack(spacing: 6) {
if !item.isDirectory { Text(item.displaySize) }
if item.isSymlink { Text("→") }
if let m = item.modified {
Text(FileBrowser.dateFormatter.string(from: m))
}
}
.font(T.monoSm)
.foregroundColor(.white.opacity(0.26))
}
Spacer()
if item.isDirectory {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white.opacity(0.18))
}
}
}
.listRowBackground(T.panel)
.contextMenu {
Button { info = item } label: { Label(t("files.info"), systemImage: "info.circle") }
Button {
renameTarget = item
renameName = item.name
} label: { Label(t("files.rename"), systemImage: "pencil") }
Button {
clipboard.stage([item.path], mode: .copy)
status = "✓ \(t("files.copied")) \(item.name)"
} label: { Label(t("files.copy"), systemImage: "doc.on.doc") }
Button {
clipboard.stage([item.path], mode: .move)
status = "→ \(t("files.move")) \(item.name)"
} label: { Label(t("files.move"), systemImage: "arrow.right.doc.on.clipboard") }
Button { duplicate(item) } label: { Label(t("files.duplicate"), systemImage: "plus.square.on.square") }
if item.isDirectory {
Button {
bookmarks.add(item.path)
status = tl("✓ 书签 ") + "\(item.name)"
} label: { Label(t("files.bookmarks"), systemImage: "bookmark") }
}
Divider()
Button(role: .destructive) { confirmDelete = item } label: {
Label(t("files.delete"), systemImage: "trash")
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) { confirmDelete = item } label: {
Label(t("files.delete"), systemImage: "trash")
}
Button {
renameTarget = item
renameName = item.name
} label: { Label(t("files.rename"), systemImage: "pencil") }
.tint(T.info)
}
.swipeActions(edge: .leading) {
Button {
clipboard.stage([item.path], mode: .copy)
} label: { Label(t("files.copy"), systemImage: "doc.on.doc") }
.tint(T.ember)
}
}
// MARK: Jump sheet
private var jumpSheet: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("files.path"))
TextField("/path/to/directory", text: $jumpPath)
.font(T.mono)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(9)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
HStack(spacing: 8) {
Button(t("files.done")) {
browser.chdir(jumpPath)
jumpShown = false
}
.font(.system(size: 13, weight: .semibold))
.tint(T.ember)
Spacer()
Button("~/Documents") {
jumpPath = Container.documents.path
}.font(T.monoSm).tint(.white.opacity(0.4))
Button("/") { jumpPath = "/" }
.font(T.monoSm).tint(.white.opacity(0.4))
}
}
}
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: t("files.bookmarks"))
ForEach(bookmarks.items, id: \.self) { path in
Button {
browser.chdir(path)
jumpShown = false
} label: {
HStack(spacing: 8) {
Image(systemName: "folder.fill")
.font(.system(size: 11)).foregroundColor(T.ember)
Text(Container.shortPath(URL(fileURLWithPath: path)))
.font(T.monoSm).foregroundColor(.white.opacity(0.7))
.lineLimit(1).truncationMode(.middle)
Spacer()
}
}
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("files.jump"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("files.cancel")) { jumpShown = false }.tint(T.ember)
}
}
}
}
// MARK: Operations
private func paste() {
let fm = FileManager.default
var ok = 0
var failed: [String] = []
for source in clipboard.paths {
let name = (source as NSString).lastPathComponent
let dest = (browser.path as NSString).appendingPathComponent(name)
guard dest != source else { continue }
do {
if fm.fileExists(atPath: dest) {
// never silently overwrite
let stem = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
var n = 2
var candidate = dest
while fm.fileExists(atPath: candidate) {
let newName = ext.isEmpty ? "\(stem) \(n)" : "\(stem) \(n).\(ext)"
candidate = (browser.path as NSString).appendingPathComponent(newName)
n += 1
}
if clipboard.mode == .copy {
try fm.copyItem(atPath: source, toPath: candidate)
} else {
try fm.moveItem(atPath: source, toPath: candidate)
}
} else {
if clipboard.mode == .copy {
try fm.copyItem(atPath: source, toPath: dest)
} else {
try fm.moveItem(atPath: source, toPath: dest)
}
}
ok += 1
} catch {
failed.append("\(name): \(Container.describe(errno))")
}
}
browser.reload()
if failed.isEmpty {
status = "✓ \(clipboard.mode == .copy ? t("files.copied") : t("files.moved")) \(ok)"
clipboard.clear()
} else {
status = failed.joined(separator: " · ")
}
}
private func duplicate(_ item: FileItem) {
let fm = FileManager.default
let dir = (item.path as NSString).deletingLastPathComponent
let stem = (item.name as NSString).deletingPathExtension
let ext = (item.name as NSString).pathExtension
var n = 2
var candidate = ""
repeat {
let name = ext.isEmpty ? "\(stem) \(n)" : "\(stem) \(n).\(ext)"
candidate = (dir as NSString).appendingPathComponent(name)
n += 1
} while fm.fileExists(atPath: candidate)
do {
try fm.copyItem(atPath: item.path, toPath: candidate)
browser.reload()
status = "✓ \(t("files.copied")) \((candidate as NSString).lastPathComponent)"
} catch {
status = Container.describe(errno)
}
}
}
// MARK: - Info sheet
struct FileInfoSheet: View {
let item: FileItem
@ObservedObject var browser: FileBrowser
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Card {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(browser.fileInfo(item).enumerated()), id: \.offset) { _, row in
KeyValueRow(key: row.0, value: row.1)
}
}
}
if !item.isDirectory {
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: t("files.hex"))
Text(FileLoader.hexDump(at: item.path, limit: 2048) ?? "—")
.font(.system(size: 9.5, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.textSelection(.enabled)
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(item.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("module.close")) { dismiss() }.tint(T.ember)
}
}
}
}
}