// // ScriptListView.swift // StikDebug // // Created by s s on 2025/7/4. // import SwiftUI import UniformTypeIdentifiers import UIKit struct ScriptListView: View { @State private var scripts: [URL] = [] @State private var showNewFileAlert = false @State private var newFileName = "" @State private var showImporter = false @AppStorage("DefaultScriptName") private var defaultScriptName = "attachDetach.js" // Overlays / feedback @State private var isBusy = false @State private var alertVisible = false @State private var alertTitle = "" @State private var alertMessage = "" @State private var alertIsSuccess = false @State private var justCopied = false // Search @State private var searchText = "" var onSelectScript: ((URL?) -> Void)? = nil private var filteredScripts: [URL] { guard !searchText.isEmpty else { return scripts } return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) } } // Column width control (adjust to taste) private let maxColumnWidth: CGFloat = 720 var body: some View { NavigationStack { ZStack { // Background gradient LinearGradient( gradient: Gradient(colors: [ Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground) ]), startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ScrollView { VStack(spacing: 20) { // Everything below shares the same fixed max width searchField .frame(maxWidth: .infinity) actionsCard .frame(maxWidth: .infinity) scriptListCard .frame(maxWidth: .infinity) footerInfo .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 30) .padding(.horizontal, 20) .frame(maxWidth: maxColumnWidth) // <- unified column width .frame(maxWidth: .infinity) // <- center the column } // Busy overlay if isBusy { Color.black.opacity(0.35).ignoresSafeArea() ProgressView("Working…") .padding(16) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) ) ) .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } // Custom alert overlay if alertVisible { CustomErrorView( title: alertTitle, message: alertMessage, onDismiss: { alertVisible = false }, showButton: true, primaryButtonText: "OK", messageType: alertIsSuccess ? .success : .error ) } // Toast if justCopied { VStack { Spacer() Text("Copied") .font(.footnote.weight(.semibold)) .padding(.horizontal, 14) .padding(.vertical, 10) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) .transition(.move(edge: .bottom).combined(with: .opacity)) .padding(.bottom, 30) } .animation(.easeInOut(duration: 0.25), value: justCopied) } } .navigationTitle("JavaScript Files") .toolbar { ToolbarItem(placement: .primaryAction) { Button { showNewFileAlert = true } label: { Label("New Script", systemImage: "plus") } } ToolbarItem(placement: .primaryAction) { Button { showImporter = true } label: { Label("Import", systemImage: "tray.and.arrow.down") } } } .onAppear(perform: loadScripts) .alert("New Script", isPresented: $showNewFileAlert) { TextField("Filename", text: $newFileName) Button("Create", action: createNewScript) Button("Cancel", role: .cancel) { } } .fileImporter( isPresented: $showImporter, allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText], allowsMultipleSelection: false ) { result in switch result { case .success(let urls): if let fileURL = urls.first { importScript(from: fileURL) } case .failure(let error): presentError(title: "File Import Error", message: error.localizedDescription) } } } } // MARK: - Glassy UI Sections private var searchField: some View { TextField("Search scripts…", text: $searchText) .padding(12) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) ) } private var actionsCard: some View { VStack(alignment: .leading, spacing: 10) { Button { showNewFileAlert = true } label: { Label("New Script", systemImage: "doc.badge.plus") } Button { showImporter = true } label: { Label("Import Script", systemImage: "tray.and.arrow.down") } } .padding(20) .background(glassBackground) } private var scriptListCard: some View { VStack(alignment: .leading, spacing: 8) { if let onSelectScript { Button { onSelectScript(nil) } label: { HStack(spacing: 8) { Image(systemName: "nosign").foregroundStyle(.secondary) Text("None") Spacer(minLength: 0) } } Divider().background(Color.white.opacity(0.12)) } if filteredScripts.isEmpty { emptyRow } else { ForEach(filteredScripts, id: \.self) { script in scriptRow(script) Divider().background(Color.white.opacity(0.12)) } } } .padding(20) .background(glassBackground) } private var footerInfo: some View { Text("Swipe left on a script to set it as default or delete it. Enable script execution after connecting in settings.") .font(.footnote) .foregroundStyle(.secondary) .padding(.horizontal, 4) } private var glassBackground: some View { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) ) .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } // MARK: - Row UI private var emptyRow: some View { VStack(alignment: .leading, spacing: 6) { Label("No scripts found", systemImage: "doc.text.magnifyingglass") .foregroundColor(.orange) Text("Tap New Script or Import to get started.") .font(.footnote) .foregroundColor(.secondary) } } private func scriptRow(_ script: URL) -> some View { let isDefault = defaultScriptName == script.lastPathComponent return HStack { Text(script.lastPathComponent) Spacer() if isDefault { Image(systemName: "star.fill") .foregroundColor(.yellow) } } .contentShape(Rectangle()) .onTapGesture { if let onSelectScript { onSelectScript(script) } else { // fallback to open editor if provided in your project // You can replace this with a NavigationLink if you prefer } } .contextMenu { Button { copyName(script) } label: { Label("Copy Filename", systemImage: "doc.on.doc") } Button { copyPath(script) } label: { Label("Copy Path", systemImage: "folder") } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { deleteScript(script) } label: { Label("Delete", systemImage: "trash") } Button { saveDefaultScript(script) } label: { Label("Set Default", systemImage: "star") } .tint(.blue) } } // MARK: - File Ops (from your original) private func scriptsDirectory() -> URL { let dir = FileManager .default .urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("scripts") var isDir: ObjCBool = false var exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) do { if exists && !isDir.boolValue { try FileManager.default.removeItem(at: dir) exists = false } if !exists { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) if let bundleURL = Bundle.main.url(forResource: "attachDetach", withExtension: "js") { let dest = dir.appendingPathComponent("attachDetach.js") if !FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.copyItem(at: bundleURL, to: dest) } } } } catch { presentError(title: "Unable to Create Scripts Folder", message: error.localizedDescription) } return dir } private func loadScripts() { let dir = scriptsDirectory() scripts = (try? FileManager .default .contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))? .filter { $0.pathExtension.lowercased() == "js" } .sorted(by: { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }) ?? [] } private func saveDefaultScript(_ url: URL) { defaultScriptName = url.lastPathComponent presentSuccess(title: "Default Script Set", message: url.lastPathComponent) } private func createNewScript() { guard !newFileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } var filename = newFileName.trimmingCharacters(in: .whitespacesAndNewlines) if !filename.hasSuffix(".js") { filename += ".js" } let newURL = scriptsDirectory().appendingPathComponent(filename) guard !FileManager.default.fileExists(atPath: newURL.path) else { presentError(title: "Failed to Create New Script", message: "A script with the same name already exists.") return } do { try "".write(to: newURL, atomically: true, encoding: .utf8) newFileName = "" loadScripts() presentSuccess(title: "Created", message: filename) } catch { presentError(title: "Error Creating File", message: error.localizedDescription) } } private func deleteScript(_ url: URL) { do { try FileManager.default.removeItem(at: url) if url.lastPathComponent == defaultScriptName { UserDefaults.standard.removeObject(forKey: "DefaultScriptName") } loadScripts() } catch { presentError(title: "Delete Failed", message: error.localizedDescription) } } private func importScript(from fileURL: URL) { isBusy = true DispatchQueue.global(qos: .userInitiated).async { defer { DispatchQueue.main.async { self.isBusy = false } } do { let dest = scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent) if FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.removeItem(at: dest) } try FileManager.default.copyItem(at: fileURL, to: dest) DispatchQueue.main.async { self.loadScripts() self.presentSuccess(title: "Imported", message: fileURL.lastPathComponent) } } catch { DispatchQueue.main.async { self.presentError(title: "Import Failed", message: error.localizedDescription) } } } } // MARK: - Feedback helpers private func presentError(title: String, message: String) { alertTitle = title alertMessage = message alertIsSuccess = false alertVisible = true } private func presentSuccess(title: String, message: String) { alertTitle = title alertMessage = message alertIsSuccess = true alertVisible = true } private func copyName(_ url: URL) { UIPasteboard.general.string = url.lastPathComponent showCopiedToast() } private func copyPath(_ url: URL) { UIPasteboard.general.string = url.path showCopiedToast() } private func showCopiedToast() { withAnimation { justCopied = true } DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { withAnimation { justCopied = false } } } }