diff --git a/Option1/BundleIds.swift b/Option1/BundleIds.swift index 47cc492..09c9c9d 100644 --- a/Option1/BundleIds.swift +++ b/Option1/BundleIds.swift @@ -1,5 +1,22 @@ +// todo Android Studio +// todo Jetbrain IDE's +// todo Microsoft Office Products: Word, Excel, ... +// todo Text Edit class BundleIds { - static let Xcode = "com.apple.dt.Xcode" - static let IntelliJ = "com.jetbrains.intellij" + static let Finder = "com.apple.finder" + static let Xcode = "com.apple.dt.Xcode" + // JetBrains + static let IntelliJ = "com.jetbrains.intellij" + static let PhpStorm = "com.jetbrains.PhpStorm" + // Microsoft + static let MicrosoftWord = "com.microsoft.Word" + + static func isOpenByShellNoNewWindow(_ bundle: String) -> Bool { + [Finder, MicrosoftWord].contains(bundle) + } + + static func isOpenByShellWithNewWindow(_ bundle: String) -> Bool { + [IntelliJ, PhpStorm].contains(bundle) + } } diff --git a/Option1/CachedWindow.swift b/Option1/CachedWindow.swift index b2edc5b..328b656 100644 --- a/Option1/CachedWindow.swift +++ b/Option1/CachedWindow.swift @@ -9,12 +9,12 @@ struct CachedWindow: Hashable { let axuiElementId: AXUIElementID let title: String let appBundle: String - let ideaProject: String? + let shellWithNewWindow: String? static func addByAxuiElement( nsRunningApplication: NSRunningApplication, axuiElement: AXUIElement, - ideaProject: String? = nil, + shellWithNewWindow: String? = nil, ) throws { if let pid = try axuiElement.pid(), @@ -29,7 +29,7 @@ struct CachedWindow: Hashable { axuiElementId: axuiElementId, title: title, appBundle: bundleIdentifier, - ideaProject: ideaProject ?? oldCachedWindow?.ideaProject, + shellWithNewWindow: shellWithNewWindow ?? oldCachedWindow?.shellWithNewWindow, ) } } diff --git a/Option1/HotKeysUtils.swift b/Option1/HotKeysUtils.swift index 6feba7e..2c8cb68 100644 --- a/Option1/HotKeysUtils.swift +++ b/Option1/HotKeysUtils.swift @@ -102,19 +102,30 @@ private func handleSpecial( return false } - if bindDb.bundle == BundleIds.IntelliJ { + if BundleIds.isOpenByShellNoNewWindow(bindDb.bundle) { let bundle = bindDb.bundle - let fileManager = FileManager.default - let project = bindDb.substring - if project.first == "/", - fileManager.fileExists(atPath: project) { + let path = bindDb.substring + if isFileExists(path) { + let result = shell("open", "-b", bundle, path) + // No sense to update cachedWindows + return result == 0 + } + return false + } + + if BundleIds.isOpenByShellWithNewWindow(bindDb.bundle) { + let bundle = bindDb.bundle + let path = bindDb.substring + if isFileExists(path) { // При вызове NSWorkspace.shared.openApplication() с createsNewApplicationInstance // macOS начинает анимацию запуска приложения в Dock, хотя по факту открывается еще // одно окно а не всё приложение. Каждый раз смотреть эти подпрыгивания не охото, - // по этому если уже есть окно с этим ideaProject - то его запуск. + // по этому если уже есть окно с этим путем - то его запуск. CachedWindow.cleanClosed__slow() - if let cachedProject = cachedWindows.first(where: { $0.value.ideaProject == project }) { + if let cachedProject = cachedWindows.first( + where: { $0.value.appBundle == bundle && $0.value.shellWithNewWindow == path } + ) { try? focusAxuiElement(cachedProject.value.axuiElement) return true } @@ -123,7 +134,7 @@ private func handleSpecial( return false } let configuration = NSWorkspace.OpenConfiguration() - configuration.arguments = [project] + configuration.arguments = [path] configuration.createsNewApplicationInstance = true NSWorkspace.shared.openApplication(at: url, configuration: configuration, completionHandler: { _, _ in // Почему-то у объекта приложения из completionHandler .bundleIdentifier всегда nil, @@ -138,7 +149,7 @@ private func handleSpecial( try? CachedWindow.addByAxuiElement( nsRunningApplication: nsApp, axuiElement: focused, - ideaProject: project, + shellWithNewWindow: path, ) } }) @@ -147,6 +158,12 @@ private func handleSpecial( return false } + if isFileExists(bindDb.substring) { + let result = shell("open", "-b", bindDb.bundle, bindDb.substring) + // No sense to update cachedWindows + return result == 0 + } + return false } diff --git a/Option1/UI/Screens/Workspace/WorkspaceBindView.swift b/Option1/UI/Screens/Workspace/WorkspaceBindView.swift index a97c51d..bbe3c27 100644 --- a/Option1/UI/Screens/Workspace/WorkspaceBindView.swift +++ b/Option1/UI/Screens/Workspace/WorkspaceBindView.swift @@ -12,6 +12,15 @@ struct WorkspaceBindView: View { @State private var appsUi: [AppUi] @State private var formUi: FormUi + @State private var isTitleInfoPresented = false + + private var selectedAppName: String? { + appsUi.first(where: { $0.bundle == formUi.bundle })?.title + } + + @State private var isAnyFilePickerPresented = false + @State private var isAnyFilePickerInfoPresented = false + // Т.к. одновременно данное View отображается 10 раз а в формировании // списка много внутренней логики нужно давать хотябы 2 секунды. private let updateAppsUiTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() @@ -56,7 +65,7 @@ struct WorkspaceBindView: View { if formUi.bundle != nil { if formUi.bundle == BundleIds.Xcode { - ProjectPickerView( + FileTypeView( path: formUi.substring, pickerButtonText: "Select Xcode Project File or Folder", fileTypes: [.data, .directory], @@ -65,7 +74,7 @@ struct WorkspaceBindView: View { }, ) } else if formUi.bundle == BundleIds.IntelliJ { - ProjectPickerView( + FileTypeView( path: formUi.substring, pickerButtonText: "Select IDEA Project Folder", fileTypes: [.directory], @@ -73,10 +82,81 @@ struct WorkspaceBindView: View { formUi.substring = path }, ) + } else if formUi.bundle == BundleIds.MicrosoftWord { + FileTypeView( + path: formUi.substring, + pickerButtonText: "Select Word Document", + fileTypes: [.data], + onPathChanged: { path in + formUi.substring = path + }, + ) + } else if isFileExists(formUi.substring) { + FileTypeView( + path: formUi.substring, + pickerButtonText: "---", // Impossible to show because of .isFileExists() + fileTypes: [.data, .directory], + onPathChanged: { path in + formUi.substring = path + }, + ) } else { - TextField("Part of title (optional)", text: $formUi.substring) + + TextField("Window title (optional)", text: $formUi.substring) .autocorrectionDisabled() - .frame(width: 200) + .frame(width: 180) + + Button( + action: { + isAnyFilePickerInfoPresented = true + }, + label: { + Image(systemName: "folder") + .font(.system(size: fontSize, weight: .regular)) + .foregroundColor(.secondary) + }, + ) + .buttonStyle(.borderless) + .padding(.leading, 12) + .confirmationDialog( + "", + isPresented: $isAnyFilePickerInfoPresented, + ) { + Button("Select File or Folder") { + isAnyFilePickerPresented = true + } + .keyboardShortcut(.defaultAction) + + Button("Cancel", role: .cancel) { + } + } message: { + Text("If \(selectedAppName ?? "the app") supports opening files or folders, select the one you want to open.\n\nIf the file doesn't open properly, please contact me. I'll research this app.") + } + .fileImporter( + isPresented: $isAnyFilePickerPresented, + allowedContentTypes: [.data, .directory], + onCompletion: { result in + switch result { + case .success(let url): + formUi.substring = url.relativePath + case .failure: + break + } + } + ) + + Button( + action: { + isTitleInfoPresented = true + }, + label: { + Image(systemName: "info.circle") + .font(.system(size: fontSize, weight: .regular)) + .foregroundColor(.secondary) + }, + ) + .buttonStyle(.borderless) + .padding(.leading, 8) } } else if let sharedOverride = sharedOverride { HStack(spacing: 0) { @@ -120,10 +200,18 @@ struct WorkspaceBindView: View { } } } + .alert( + "", + isPresented: $isTitleInfoPresented, + actions: {}, + message: { Text("If you have multiple \(selectedAppName ?? "app") windows open, enter the window title for window you want to open.\n\nYou can enter part of title as well.") } + ) } } -private struct ProjectPickerView: View { +private let userRelativePathRegex = /^\/Users\/(.*?)\/\b/ + +private struct FileTypeView: View { let path: String let pickerButtonText: String @@ -134,6 +222,10 @@ private struct ProjectPickerView: View { @State private var isFilePickerPresented = false + private var validatedPath: String { + path.replacing(userRelativePathRegex, with: "~/") + } + var body: some View { HStack(spacing: 4) { if path.isEmpty { @@ -146,9 +238,9 @@ private struct ProjectPickerView: View { }, ) } else { - Text(path) + Text(validatedPath) .padding(.vertical, 8) - .foregroundColor(.green) + .foregroundColor(.blue) .font(.system(size: fontSize, weight: .regular)) .onTapGesture { isFilePickerPresented = true @@ -160,7 +252,7 @@ private struct ProjectPickerView: View { }, label: { Image(systemName: "xmark.circle") - .font(.system(size: fontSize, weight: .medium)) + .font(.system(size: fontSize, weight: .regular)) }, ) .buttonStyle(.borderless) diff --git a/Option1/UI/Screens/Workspace/WorkspaceScreen.swift b/Option1/UI/Screens/Workspace/WorkspaceScreen.swift index 8ea4e36..74bb5e9 100644 --- a/Option1/UI/Screens/Workspace/WorkspaceScreen.swift +++ b/Option1/UI/Screens/Workspace/WorkspaceScreen.swift @@ -26,7 +26,7 @@ struct WorkspaceScreen: View { Divider() .padding() - Text("Open Window Titles") + Text("Window Titles") .font(.system(size: 20, weight: .semibold)) .padding(.horizontal) .textAlign(.leading)