Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ section's name.
Anything else goes before the first section, or in its own section after
these four.

## [Unreleased]

### New

- **Run your scripts on a result.** Add a `@floe.subjects` line to a script command, such as `# @floe.subjects file, folder`, and it shows at the end of the Actions menu (⌘K) of apps, files, folders or links instead of in the search. In Settings › Commands › Actions it can be what Return does, have its own keys, or be left out of the menu. The script is told what it acts on in environment variables, such as `FLOE_PATH` and `FLOE_NAME`. [INTERFACES.md](docs/INTERFACES.md#actions-on-results) lists them all.

## [0.4.0-beta.3] - 2026-11-05

**macOS 26 and later · Build 8**
Expand Down
4 changes: 4 additions & 0 deletions Floe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@
A6C6CEA52FE88F03991F5AE9 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208ECA39C6285356E6199F7A /* Contacts.swift */; };
A79B81EE2B1D08AB671D4B8A /* ShellRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB126E15016D489A2EDAAB59 /* ShellRows.swift */; };
A801729F67E721311FCDCD13 /* OutgoingDrafts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80740EC675CC466D11F41A52 /* OutgoingDrafts.swift */; };
A91ECE6EB14E57F5E4FF2782 /* ScriptActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F1074D99C608E508045015 /* ScriptActions.swift */; };
A927D7580E12A9B670504416 /* Model+Catalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA9FB3C79E7CCFC3BC79521 /* Model+Catalog.swift */; };
AA9773F82E103CF7CF9F5940 /* CalculatorFormatter+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86360FB59649B01FDD998C /* CalculatorFormatter+Display.swift */; };
AA9C7E355B2F52EE97B6C97B /* TextGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46789D9D3D1E774F7F4F3A5F /* TextGeneration.swift */; };
Expand Down Expand Up @@ -693,6 +694,7 @@
95F42770ECB0255BA11434C8 /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = "<group>"; };
962E6B4A1AD362AEF3BE6C57 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };
96930AAEECE7733AB82EE655 /* SettingsSearchNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchNavigation.swift; sourceTree = "<group>"; };
96F1074D99C608E508045015 /* ScriptActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptActions.swift; sourceTree = "<group>"; };
975C93B46D2CE2C1CB09E1ED /* AppleScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScript.swift; sourceTree = "<group>"; };
97A5B790800A314D24197729 /* OpenWindows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenWindows.swift; sourceTree = "<group>"; };
98ACB02E799D643634A013D4 /* Model+Hotkeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Model+Hotkeys.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1437,6 +1439,7 @@
E7DCC87E199BD39B7930CE31 /* ActionKeys.swift */,
AFD6BB901BAC3B6C007C605B /* ActionOptions.swift */,
F18A54F7E441F670A08C145F /* AlternativeActions.swift */,
96F1074D99C608E508045015 /* ScriptActions.swift */,
);
path = Actions;
sourceTree = "<group>";
Expand Down Expand Up @@ -1981,6 +1984,7 @@
C7736ACC6DCFDB827967C091 /* SSHSearch.swift in Sources */,
9BCA580FCA8421373D07A616 /* ScopeRows.swift in Sources */,
C0C507DB95A13EA96467BB39 /* ScreenTools.swift in Sources */,
A91ECE6EB14E57F5E4FF2782 /* ScriptActions.swift in Sources */,
132B83E8D45E3CE01CD04852 /* ScriptCommands.swift in Sources */,
086B6949B11CB58A65E4BFDC /* ScriptRows.swift in Sources */,
4902891941C787A1667EB50E /* SearchBench.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions Sources/Floe/Actions/ActionChoices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ extension AppSettings {
var floeHotkeys: Set<KeyCombination> {
Set(commandHotkeys.values).union(toggleHotkey.map { [$0] } ?? [])
}

/// Takes the hotkeys off scripts that act on results. Such a script runs on the result selected, which a hotkey
/// pressed anywhere has none of, and a hotkey left on it would still keep its keys from every row.
func dropHotkeys(of actionScripts: [ScriptCommand]) {
for script in actionScripts where commandHotkeys[script.id] != nil {
commandHotkeys[script.id] = nil
}
}
}

extension ActionID: CodingKeyRepresentable {
Expand Down
7 changes: 4 additions & 3 deletions Sources/Floe/Actions/ActionKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ struct ActionKeys: Equatable {
}

/// The keys in force on a kind's rows: Floe's, with the user's choices laid over them, and none of Floe's own
/// hotkeys, such as the one that opens it. The Mac takes those before a row could have them.
static func inForce(for kind: SubjectKind?, choices: KindActions?, hotkeys: Set<KeyCombination>) -> ActionKeys {
/// hotkeys, such as the one that opens it. The Mac takes those before a row could have them. `scripts` are the
/// user's scripts on offer: keys left on any other, one renamed or taken away, never keep keys from a row that is.
static func inForce(for kind: SubjectKind?, choices: KindActions?, hotkeys: Set<KeyCombination>, scripts: Set<ActionID>) -> ActionKeys {
let floe = floe(for: kind)
var result = choices.map { floe.applying($0) } ?? floe
var result = choices.map { floe.applying($0.keeping(scripts: scripts)) } ?? floe
if !hotkeys.isEmpty {
result.keys = result.keys.filter { !hotkeys.contains($0.value) }
}
Expand Down
15 changes: 11 additions & 4 deletions Sources/Floe/Actions/ActionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ extension ActionOption {
/// Floe's own rows on a kind of result, in the order its Actions menu has them. A row only some results have,
/// such as Quit, is listed all the same; one that depends on the result, such as an app in Open With, is left to
/// its submenu's row. `roleApps` are the roles a file opens in with their apps, as the launcher resolves them.
static func rows(for kind: SubjectKind, roleApps: [RoleApp]) -> [ActionOption] {
/// The user's scripts that act on the kind come after Floe's rows, as the menu has them.
static func rows(for kind: SubjectKind, roleApps: [RoleApp], scripts: [ScriptCommand] = []) -> [ActionOption] {
floeRows(for: kind, roleApps: roleApps)
+ scripts.acting(on: kind).map { ActionOption(id: $0.actionID, title: $0.title) }
}

private static func floeRows(for kind: SubjectKind, roleApps: [RoleApp]) -> [ActionOption] {
let openWith = ActionOption(id: .openWith, title: String(localized: "Open With", bundle: .floe, comment: "The title of a submenu that lists apps."), isSubmenu: true)
let roles = roleApps.map { entry in
ActionOption(id: .openIn(entry.role), title: String(localized: "Open in \(entry.app.name)", bundle: .floe, comment: "The placeholder is the name of an app."))
Expand Down Expand Up @@ -106,8 +112,9 @@ extension KindActions {

/// The keys that run a row: Floe's for the kind, with the user's laid over them and none of Floe's hotkeys, as
/// the launcher has them.
func keys(for id: ActionID, of kind: SubjectKind, hotkeys: Set<KeyCombination> = []) -> KeyCombination? {
ActionKeys.inForce(for: kind, choices: self, hotkeys: hotkeys).key(for: id)
/// `rows` are those listed, the user's scripts among them.
func keys(for id: ActionID, of kind: SubjectKind, among rows: [ActionOption] = [], hotkeys: Set<KeyCombination> = []) -> KeyCombination? {
ActionKeys.inForce(for: kind, choices: self, hotkeys: hotkeys, scripts: Set(rows.map(\.id)).union([id])).key(for: id)
}

/// Gives a row keys, or with nil takes its keys away, Floe's included. Floe's own keys for the row are stored as
Expand Down Expand Up @@ -170,7 +177,7 @@ enum KeyRefusal: Equatable {
self = keys.modifiers.contains(.command) ? .reserved(keys) : .needsCommand(keys)
return
}
let inForce = ActionKeys.inForce(for: kind, choices: choices, hotkeys: hotkeys)
let inForce = ActionKeys.inForce(for: kind, choices: choices, hotkeys: hotkeys, scripts: Set(rows.map(\.id)))
guard let other = rows.first(where: { $0.id != id && inForce.key(for: $0.id) == keys }) else { return nil }
self = .taken(keys, by: other.title)
}
Expand Down
39 changes: 36 additions & 3 deletions Sources/Floe/Actions/AlternativeActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import AppKit
import ThawUI

/// The user's choices on apps, files, folders and web addresses: what Return does, which rows the Actions menu
/// shows, and the keys on its rows. Nothing chosen leaves every result as Floe has it.
/// shows, and the keys on its rows. Nothing chosen leaves every result as Floe has it. Script commands with a
/// `@floe.subjects` line are rows of the user's own, after Floe's.
final class AlternativeActions {
/// What the feature asks of the launcher.
struct Host {
var choices: () -> [String: KindActions] = { [:] }
/// Floe's own rows of a result's Actions menu, after its Return row.
/// The rows of a result's Actions menu after its Return row, the user's scripts on it among them.
var floeActions: (RootItem) -> [ItemAction?] = { _ in [] }
/// What Floe's own Return on a result is called.
var floeTitle: (RootItem) -> String = { _ in "" }
Expand All @@ -34,6 +35,12 @@ final class AlternativeActions {
var offerState: (RootItem) -> Int = { _ in 0 }
/// Floe's own hotkeys, which no row's keys can be.
var hotkeys: () -> Set<KeyCombination> = { [] }
/// The user's script commands, those that act on results among them.
var scripts: () -> [ScriptCommand] = { [] }
/// Runs a script on the result it acts on.
var runScript: (ScriptCommand, ActionSubject) -> Void = { _, _ in
// Set by LauncherFeatures.
}
}

var host = Host()
Expand Down Expand Up @@ -77,14 +84,40 @@ final class AlternativeActions {
return [returnRow] + AlternativeActionRules.arranged(rows, own: own, primaryID: primary?.id, choices: choices)
}

/// A result's rows with their keys: the view's own, then the user's scripts that act on its kind. Each view
/// builds its rows here, so wherever a row has keys, the scripts are on offer to Return, the menu and keys too.
func rows(_ own: [ItemAction?], for item: RootItem) -> [ItemAction?] {
// Read again, as rows are built on a key or a menu, not on every draw: a path can be a folder by now.
lastKind = nil
let kind = kind(of: item)
let scripts = scripts(on: kind)
let keys = keys(of: kind, scripts: scripts)
guard !scripts.isEmpty, let kind, let subject = ActionSubject.of(item, kind: kind) else { return keys.keyed(own) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let scriptRows = scripts.map { script -> ItemAction? in
ItemAction(title: script.title, symbol: "terminal", id: script.actionID) { [host] in
host.runScript(script, subject)
}
}
return keys.keyed(own + [nil] + scriptRows)
}

/// The keys on a result's rows: Floe's for its kind, with the keys the user chose laid over them.
func keys(for item: RootItem) -> ActionKeys {
keys(of: kind(of: item))
}

/// The keys on the rows of a kind of result, as `keys(for:)` gives them on a result of that kind.
func keys(of kind: SubjectKind?) -> ActionKeys {
ActionKeys.inForce(for: kind, choices: choices(of: kind), hotkeys: host.hotkeys())
keys(of: kind, scripts: scripts(on: kind))
}

private func keys(of kind: SubjectKind?, scripts: [ScriptCommand]) -> ActionKeys {
ActionKeys.inForce(for: kind, choices: choices(of: kind), hotkeys: host.hotkeys(), scripts: Set(scripts.map(\.actionID)))
}

/// The user's scripts that act on a kind of result; none on a result with no kind.
private func scripts(on kind: SubjectKind?) -> [ScriptCommand] {
kind.map { host.scripts().acting(on: $0) } ?? []
}

// MARK: - Working it out
Expand Down
Loading
Loading