YanDisk v1.0.1: тёмная иконка по теме системы, вкладки (Скриншоты скрыт, Файлы→Доступные мне), синк по умолчанию 'В облаке', ПРАВКИ.md
- Иконка: AppIconDark.icns + переключение Dock-иконки по effectiveAppearance (KVO)
- Сайдбар: убран 'Скриншоты', 'Файлы' → 'Доступные мне' (заглушка), первая вкладка — все файлы и папки
- SyncPolicyStore.globalMode по умолчанию .cloudOnly ('В облаке')
- build.sh/smoke_test.sh: SDKROOT из Xcode (фикс SDK-конфликта тулчейна 6.3.3/SDK 6.4)
- ПРАВКИ.md: полный аудит и план доработок
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
@Bindable var store = store
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "cloud.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(Color(red: 0.93, green: 0.0, blue: 0.21))
|
||||
|
||||
Text("YanDisk")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("Войдите, чтобы синхронизировать файлы")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
store.openAuthorization()
|
||||
} label: {
|
||||
Label("Войти через Яндекс", systemImage: "person.crop.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(Color(red: 0.93, green: 0.0, blue: 0.21))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Вставьте OAuth-токен")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("access_token или ссылка с токеном", text: $store.pasteToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Text("После входа скопируйте токен со страницы Яндекса и вставьте сюда.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button("Продолжить") {
|
||||
store.login(with: store.pasteToken)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(store.pasteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
if let error = store.errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Text("Client ID: \(OAuthManager.clientId)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(width: 420)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct MainSplitView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var showNewFolderAlert = false
|
||||
@State private var newFolderName = ""
|
||||
@State private var renameItem: DiskResource?
|
||||
@State private var renameText = ""
|
||||
|
||||
var body: some View {
|
||||
@Bindable var store = store
|
||||
NavigationSplitView {
|
||||
List(AppStore.SidebarItem.displayCases, selection: $store.sidebarSelection) { item in
|
||||
Label(item.title, systemImage: item.systemImage)
|
||||
.tag(item)
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if store.diskInfo != nil {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Место на Диске")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: store.usedPercent)
|
||||
Text("\(store.formattedUsed) / \(store.formattedTotal)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
if store.sidebarSelection == .tasks {
|
||||
TasksView()
|
||||
} else {
|
||||
pathBar(store: store)
|
||||
Divider()
|
||||
fileBrowser(store: store)
|
||||
}
|
||||
if let status = store.statusMessage {
|
||||
Divider()
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle")
|
||||
Text(status)
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
Button {
|
||||
showNewFolderAlert = true
|
||||
} label: {
|
||||
Label("Новая папка", systemImage: "folder.badge.plus")
|
||||
}
|
||||
|
||||
Button {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseDirectories = true
|
||||
if panel.runModal() == .OK {
|
||||
Task { await store.uploadFiles(from: panel.urls) }
|
||||
}
|
||||
} label: {
|
||||
Label("Загрузить", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
if store.sidebarSelection == .trash {
|
||||
Button {
|
||||
Task { await store.emptyTrash() }
|
||||
} label: {
|
||||
Label("Очистить Корзину", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Режим", selection: Binding(
|
||||
get: { store.syncEngine.policy.globalMode },
|
||||
set: { store.syncEngine.setGlobalMode($0) }
|
||||
)) {
|
||||
ForEach(SyncMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Button {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.sendAction(#selector(AppDelegate.showPreferencesWindow(_:)), to: nil, from: nil)
|
||||
} label: {
|
||||
Label("Настройки", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $store.showShareSheet) {
|
||||
ShareSheetView()
|
||||
.environment(store)
|
||||
}
|
||||
.sheet(isPresented: $store.showLoginSheet) {
|
||||
LoginView()
|
||||
.environment(store)
|
||||
}
|
||||
.alert("Новая папка", isPresented: $showNewFolderAlert) {
|
||||
TextField("Имя папки", text: $newFolderName)
|
||||
Button("Создать") {
|
||||
let name = newFolderName
|
||||
newFolderName = ""
|
||||
Task { await store.createFolder(named: name) }
|
||||
}
|
||||
Button("Отмена", role: .cancel) {}
|
||||
}
|
||||
.alert("Переименовать", isPresented: Binding(
|
||||
get: { renameItem != nil },
|
||||
set: { if !$0 { renameItem = nil } }
|
||||
)) {
|
||||
TextField("Новое имя", text: $renameText)
|
||||
Button("OK") {
|
||||
if let item = renameItem {
|
||||
let name = renameText
|
||||
renameItem = nil
|
||||
Task { await store.rename(item, to: name) }
|
||||
}
|
||||
}
|
||||
Button("Отмена", role: .cancel) { renameItem = nil }
|
||||
}
|
||||
.onAppear { store.bootstrap() }
|
||||
.onChange(of: store.sidebarSelection) { _, newValue in
|
||||
store.selectSidebar(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func pathBar(store: AppStore) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
store.goBack()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
}
|
||||
.disabled(store.pathStack.count <= 1 || store.sidebarSelection == .trash)
|
||||
|
||||
Button {
|
||||
Task { await store.refreshAll() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Text(breadcrumb(store: store))
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
TextField("Поиск", text: Binding(
|
||||
get: { store.searchText },
|
||||
set: { store.searchText = $0 }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 180)
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
private func breadcrumb(store: AppStore) -> String {
|
||||
switch store.sidebarSelection {
|
||||
case .trash: return "Корзина"
|
||||
case .recents: return "Недавние"
|
||||
case .favorites: return "Избранное"
|
||||
case .screenshots: return "Скриншоты"
|
||||
case .files: return "Доступные мне"
|
||||
default:
|
||||
if store.currentPath == "/" { return "YanDisk" }
|
||||
return store.currentPath
|
||||
}
|
||||
}
|
||||
|
||||
private func visibleItems(store: AppStore) -> [DiskResource] {
|
||||
switch store.sidebarSelection {
|
||||
case .trash: return store.trashItems
|
||||
case .recents: return store.recentItems
|
||||
case .files: return [] // «Доступные мне» — заглушка до реализации API
|
||||
default: return store.filteredItems
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func fileBrowser(store: AppStore) -> some View {
|
||||
let items = visibleItems(store: store)
|
||||
if store.isLoading && items.isEmpty {
|
||||
ProgressView("Загрузка…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if items.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: store.sidebarSelection == .trash ? "trash" : "folder")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(store.sidebarSelection == .trash ? "Корзина пуста" : "Папка пуста")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
Table(items, selection: Binding(
|
||||
get: { store.selection },
|
||||
set: { store.selection = $0 }
|
||||
)) {
|
||||
TableColumn("Имя") { item in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon(for: item))
|
||||
.foregroundStyle(item.isDirectory ? Color.accentColor : .secondary)
|
||||
.frame(width: 18)
|
||||
Text(item.displayName)
|
||||
.lineLimit(1)
|
||||
if item.publicUrl != nil {
|
||||
Image(systemName: "link")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
TableColumn("Размер") { item in
|
||||
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.width(100)
|
||||
TableColumn("Изменён") { item in
|
||||
Text(formattedDate(item.modified))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.width(140)
|
||||
TableColumn("Синхр.") { item in
|
||||
Text(store.syncEngine.policy.mode(for: item.path).shortTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.width(100)
|
||||
}
|
||||
.contextMenu(forSelectionType: String.self) { ids in
|
||||
if let path = ids.first, let item = items.first(where: { $0.path == path }) {
|
||||
contextMenu(store: store, item: item)
|
||||
}
|
||||
} primaryAction: { ids in
|
||||
if let path = ids.first, let item = items.first(where: { $0.path == path }) {
|
||||
store.open(item)
|
||||
}
|
||||
}
|
||||
.dropDestination(for: URL.self) { urls, _ in
|
||||
let files = urls.filter { $0.isFileURL }
|
||||
guard !files.isEmpty else { return false }
|
||||
Task { await store.uploadFiles(from: files) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contextMenu(store: AppStore, item: DiskResource) -> some View {
|
||||
if store.sidebarSelection == .trash {
|
||||
Button("Восстановить") {
|
||||
Task { await store.restoreFromTrash(item) }
|
||||
}
|
||||
} else {
|
||||
Button("Открыть") { store.open(item) }
|
||||
Button("Показать в Finder") { store.revealInFinder(item) }
|
||||
Button("Создать копию на компьютере") {
|
||||
Task { await store.downloadToComputer(item) }
|
||||
}
|
||||
Divider()
|
||||
Button("Скопировать ссылку") {
|
||||
Task { await store.copyPublicLink(item) }
|
||||
}
|
||||
Button("Настроить общий доступ") {
|
||||
store.prepareShare(item)
|
||||
}
|
||||
Divider()
|
||||
Menu("Синхронизация") {
|
||||
ForEach(SyncMode.allCases) { mode in
|
||||
Button(mode.title) {
|
||||
Task { await store.applySyncMode(mode, to: item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Скопировать «\(item.displayName)»") {
|
||||
Task { await store.duplicate(item) }
|
||||
}
|
||||
Button("Переместить в Корзину") {
|
||||
Task { await store.moveToTrash(item) }
|
||||
}
|
||||
Divider()
|
||||
Button("Переименовать") {
|
||||
renameItem = item
|
||||
renameText = item.displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func icon(for item: DiskResource) -> String {
|
||||
if item.isDirectory { return "folder.fill" }
|
||||
let ext = (item.displayName as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "png", "jpg", "jpeg", "gif", "heic", "webp": return "photo"
|
||||
case "mp4", "mov", "m4v": return "film"
|
||||
case "mp3", "m4a", "wav": return "music.note"
|
||||
case "pdf": return "doc.richtext"
|
||||
case "zip", "rar", "7z": return "doc.zipper"
|
||||
default: return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ raw: String?) -> String {
|
||||
guard let raw else { return "—" }
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
var date = formatter.date(from: raw)
|
||||
if date == nil {
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
date = formatter.date(from: raw)
|
||||
}
|
||||
guard let date else { return raw }
|
||||
let out = DateFormatter()
|
||||
out.locale = Locale(identifier: "ru_RU")
|
||||
out.dateFormat = "dd.MM.yyyy HH:mm"
|
||||
return out.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct TasksView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
if store.syncEngine.tasks.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("Нет активных задач")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List(store.syncEngine.tasks) { task in
|
||||
HStack {
|
||||
Image(systemName: task.kind == .upload ? "arrow.up.circle" : "arrow.down.circle")
|
||||
.foregroundStyle(task.isError ? Color.red : Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text((task.path as NSString).lastPathComponent)
|
||||
.lineLimit(1)
|
||||
Text(task.kind.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if task.isFinished {
|
||||
Image(systemName: task.isError ? "xmark.circle.fill" : "checkmark.circle.fill")
|
||||
.foregroundStyle(task.isError ? .red : .green)
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct MenuBarView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var showLogin = false
|
||||
@AppStorage("Appearance") private var appearance = "system"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
statusSection
|
||||
Divider()
|
||||
actionsSection
|
||||
Divider()
|
||||
folderSection
|
||||
Divider()
|
||||
footerSection
|
||||
}
|
||||
.frame(width: 320)
|
||||
.preferredColorScheme(colorScheme)
|
||||
.onAppear {
|
||||
store.bootstrap()
|
||||
if !store.isAuthorized {
|
||||
showLogin = true
|
||||
}
|
||||
}
|
||||
.onChange(of: store.showLoginSheet) { _, value in
|
||||
showLogin = value
|
||||
}
|
||||
.sheet(isPresented: $showLogin) {
|
||||
LoginView()
|
||||
.environment(store)
|
||||
}
|
||||
}
|
||||
|
||||
private var colorScheme: ColorScheme? {
|
||||
switch appearance {
|
||||
case "light": return .light
|
||||
case "dark": return .dark
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "cloud.fill")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(Color(red: 0.93, green: 0.0, blue: 0.21))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("YanDisk")
|
||||
.font(.headline)
|
||||
Text(store.isAuthorized ? "Подключён" : "Не авторизован")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
private var statusSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(store.syncEngine.state.label)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
}
|
||||
if store.diskInfo != nil {
|
||||
Text("\(store.formattedUsed) из \(store.formattedTotal)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: store.usedPercent)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
HStack {
|
||||
Text("Режим:")
|
||||
Picker("", selection: Binding(
|
||||
get: { store.syncEngine.policy.globalMode },
|
||||
set: { store.syncEngine.setGlobalMode($0) }
|
||||
)) {
|
||||
ForEach(SyncMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch store.syncEngine.state {
|
||||
case .success: return .green
|
||||
case .syncing: return .orange
|
||||
case .offline: return .gray
|
||||
case .error: return .red
|
||||
case .idle: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var actionsSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
MenuBarButton(icon: "folder", title: "Открыть YanDisk") {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.sendAction(#selector(AppDelegate.showMainWindow), to: nil, from: nil)
|
||||
}
|
||||
MenuBarButton(icon: "arrow.triangle.2.circlepath", title: "Синхронизировать сейчас") {
|
||||
Task { await store.syncEngine.syncNow() }
|
||||
}
|
||||
MenuBarButton(icon: "rectangle.and.pencil.and.ellipsis", title: "Загрузить файлы…") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = true
|
||||
if panel.runModal() == .OK {
|
||||
Task { await store.uploadFiles(from: panel.urls) }
|
||||
}
|
||||
}
|
||||
MenuBarButton(icon: "folder.badge.gearshape", title: "Показать папку синхронизации") {
|
||||
try? FileManager.default.createDirectory(
|
||||
at: store.syncEngine.syncFolderURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
NSWorkspace.shared.open(store.syncEngine.syncFolderURL)
|
||||
}
|
||||
MenuBarButton(icon: "gearshape", title: "Настройки") {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.sendAction(#selector(AppDelegate.showPreferencesWindow(_:)), to: nil, from: nil)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var folderSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("СИНХРОНИЗАЦИЯ")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
HStack {
|
||||
Text("Весь Диск")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.syncEngine.policy.mode(for: "/") },
|
||||
set: { newValue in
|
||||
Task { await store.syncEngine.applyModeChange(newValue, path: "/") }
|
||||
}
|
||||
)) {
|
||||
ForEach(SyncMode.allCases) { m in
|
||||
Text(m.shortTitle).tag(m)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
Toggle("Включить синхронизацию", isOn: Binding(
|
||||
get: { store.syncEngine.isSyncEnabled },
|
||||
set: { store.syncEngine.setSyncEnabled($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private var footerSection: some View {
|
||||
HStack {
|
||||
if store.isAuthorized {
|
||||
Button("Выйти") {
|
||||
store.logout()
|
||||
}
|
||||
} else {
|
||||
Button("Добавить аккаунт") {
|
||||
store.openAuthorization()
|
||||
showLogin = true
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Закрыть") {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarButton: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 16)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct PreferencesView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@AppStorage("Appearance") private var appearance = "system"
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
generalTab
|
||||
.tabItem { Label("Основные", systemImage: "gearshape") }
|
||||
syncTab
|
||||
.tabItem { Label("Синхронизация", systemImage: "arrow.triangle.2.circlepath") }
|
||||
accountTab
|
||||
.tabItem { Label("Аккаунт", systemImage: "person.crop.circle") }
|
||||
aboutTab
|
||||
.tabItem { Label("О программе", systemImage: "info.circle") }
|
||||
}
|
||||
.padding(8)
|
||||
.preferredColorScheme(colorScheme)
|
||||
}
|
||||
|
||||
private var colorScheme: ColorScheme? {
|
||||
switch appearance {
|
||||
case "light": return .light
|
||||
case "dark": return .dark
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var generalTab: some View {
|
||||
Form {
|
||||
Picker("Тема", selection: $appearance) {
|
||||
Text("Как в системе").tag("system")
|
||||
Text("Светлая").tag("light")
|
||||
Text("Тёмная").tag("dark")
|
||||
}
|
||||
|
||||
Toggle("Включить синхронизацию", isOn: Binding(
|
||||
get: { store.syncEngine.isSyncEnabled },
|
||||
set: { store.syncEngine.setSyncEnabled($0) }
|
||||
))
|
||||
|
||||
Picker("Режим по умолчанию", selection: Binding(
|
||||
get: { store.syncEngine.policy.globalMode },
|
||||
set: { store.syncEngine.setGlobalMode($0) }
|
||||
)) {
|
||||
ForEach(SyncMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Папка синхронизации") {
|
||||
HStack {
|
||||
Text(store.syncEngine.syncFolderURL.path)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Изменить…") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Выбрать"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
store.syncEngine.setSyncFolder(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Показать в Finder") {
|
||||
NSWorkspace.shared.open(store.syncEngine.syncFolderURL)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var syncTab: some View {
|
||||
Form {
|
||||
Section("Режимы синхронизации") {
|
||||
ForEach(SyncMode.allCases) { mode in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.title).font(.headline)
|
||||
Text(modeHelp(mode))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Переопределения по папкам") {
|
||||
if store.syncEngine.policy.pathModes.isEmpty {
|
||||
Text("Нет переопределений")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(store.syncEngine.policy.pathModes.sorted(by: { $0.key < $1.key }), id: \.key) { path, mode in
|
||||
HStack {
|
||||
Text(path)
|
||||
Spacer()
|
||||
Text(mode.title)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
store.syncEngine.policy.clearMode(for: path)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Синхронизировать сейчас") {
|
||||
Task { await store.syncEngine.syncNow() }
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var accountTab: some View {
|
||||
Form {
|
||||
if store.isAuthorized {
|
||||
LabeledContent("Статус") {
|
||||
Text("Авторизован")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
LabeledContent("Client ID") {
|
||||
Text(OAuthManager.clientId)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button("Выйти из аккаунта", role: .destructive) {
|
||||
store.logout()
|
||||
}
|
||||
} else {
|
||||
Text("Аккаунт не подключён")
|
||||
Button("Добавить аккаунт") {
|
||||
store.openAuthorization()
|
||||
store.showLoginSheet = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var aboutTab: some View {
|
||||
Form {
|
||||
LabeledContent("Приложение") {
|
||||
Text("YanDisk")
|
||||
}
|
||||
LabeledContent("Версия") {
|
||||
Text("1.0.0")
|
||||
}
|
||||
LabeledContent("API") {
|
||||
Text("REST cloud-api.yandex.net")
|
||||
.font(.caption)
|
||||
}
|
||||
Text("Клиент для macOS на Apple Silicon. Чтение, запись, копирование, перемещение, корзина, синхронизация и общий доступ.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func modeHelp(_ mode: SyncMode) -> String {
|
||||
switch mode {
|
||||
case .everywhere: return "Файлы есть и на компьютере, и в облаке. Двусторонняя синхронизация."
|
||||
case .computerOnly: return "Сохранять на компьютере; новые файлы из облака также скачиваются."
|
||||
case .cloudOnly: return "Оставить только в облаке: локальная копия удаляется."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ShareSheetView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@Bindable var store = store
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Image(systemName: "person.2.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(Color(red: 0.93, green: 0.0, blue: 0.21))
|
||||
VStack(alignment: .leading) {
|
||||
Text("Общий доступ")
|
||||
.font(.title3.bold())
|
||||
if let item = store.shareTarget {
|
||||
Text(item.displayName)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Picker("Права", selection: $store.shareSettings.rights) {
|
||||
ForEach([ShareRight.read, .write, .readOnly, .readWithPassword, .readWithPasswordNoDownload]) { right in
|
||||
Text(right.title).tag(right)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Доступен всем, у кого есть ссылка", isOn: $store.shareSettings.everyone)
|
||||
|
||||
SecureField("Пароль (необязательно)", text: $store.shareSettings.password)
|
||||
|
||||
Picker("Срок действия", selection: Binding(
|
||||
get: { store.shareSettings.availableUntilSeconds ?? 0 },
|
||||
set: { store.shareSettings.availableUntilSeconds = $0 == 0 ? nil : $0 }
|
||||
)) {
|
||||
Text("Без ограничений").tag(0)
|
||||
Text("1 час").tag(3600)
|
||||
Text("1 день").tag(86400)
|
||||
Text("7 дней").tag(604800)
|
||||
Text("30 дней").tag(2_592_000)
|
||||
}
|
||||
|
||||
if let url = store.shareURL, !url.isEmpty {
|
||||
HStack {
|
||||
TextField("Ссылка", text: .constant(url))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(true)
|
||||
Button("Копировать") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
if let item = store.shareTarget, item.publicUrl != nil {
|
||||
Button("Закрыть доступ", role: .destructive) {
|
||||
Task {
|
||||
await store.unpublishShare(item)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Отмена") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Поделиться") {
|
||||
Task {
|
||||
await store.publishShare()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color(red: 0.93, green: 0.0, blue: 0.21))
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 460)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user