{
  "format" : 1,
  "manifest" : {
    "accent" : "graphite",
    "capabilities" : [
      {
        "windows" : {

        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Workspaces",
    "id" : "io.navbytes.stackbar.plugins.workspaces",
    "minimumStackbar" : "0.17.0",
    "name" : "Workspaces",
    "summary" : "Saves where your windows are for each monitor setup, and puts them back when you dock, undock or wake.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings"
    ],
    "symbol" : "rectangle.3.group",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "wake" : {

        }
      },
      {
        "displays" : {

        }
      }
    ],
    "version" : "1.1.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "u/CmgNhiGJiG57pcvBW+okJbhiIGFUA7YXgxlWZXdP7j7yTkInQk+5XLBf3HSM4yx0Bv1Y2Z6M3GUlnHcI9CDg==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Remembers where your windows go for each monitor setup and puts them\n/// back. Save the arrangement you like at the desk and the one you like on\n/// the laptop alone; plug in or unplug a display and the setup's default\n/// layout comes back by itself, apps that weren't running included. Every\n/// layout is a command, so it can have a hotkey. Needs `.windows`, and\n/// Accessibility access for Stackbar.\n@main\nfinal class Workspaces: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.workspaces\",\n        name: \"Workspaces\",\n        version: \"1.1.0\",\n        summary: \"Saves where your windows are for each monitor setup, and puts them back when you dock, undock or wake.\",\n        surfaces: [.menuBar, .panel, .settings],\n        triggers: [.launch, .wake, .displays],\n        capabilities: [.windows],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Workspaces\",\n        accent: .graphite,\n        symbol: \"rectangle.3.group\"\n    )\n\n    // MARK: Previews\n\n    /// Outside the app the windows are simulated: a laptop with an external display.\n    static let previews: [PreviewScene<Workspaces>] = [\n        PreviewScene(\"hero\", \"Save your windows as a layout and get them back\") { plugin in\n            try await plugin.refresh()\n            for name in [\"Coding\", \"Writing\", \"Meetings\"] { _ = await plugin.save(name) }\n            plugin.notice = nil\n        },\n    ]\n\n    /// Calm and neutral: windows are the user's content, not ours.\n    let theme = Theme.graphite\n\n    /// One saved arrangement, tied to the setup it was saved on.\n    struct Layout: Codable, Identifiable, Equatable {\n        var id: String\n        var name: String\n        var setupID: String\n        /// The displays in words, as they were when it was saved.\n        var setupName: String\n        var placements: [WindowPlacement]\n        var savedAt: Date\n\n        var commandID: String { \"restore-\\(id)\" }\n        var windowCount: Int { placements.count }\n    }\n\n    @Stored(\"layouts\") var layouts: [Layout] = []\n    /// The layout that comes back by itself, by setup id.\n    @Stored(\"defaults\") var defaults: [String: String] = [:]\n    @Stored(\"autoRestore\") var autoRestore = true\n    @Stored(\"openApps\") var openApps = true\n    @Stored(\"lastSetup\") var lastSetup = \"\"\n\n    var snapshot: WindowSnapshot?\n    var problem: String?\n    var notice: String?\n\n    func refresh() async throws {\n        let reason = Stackbar.refreshReason\n        guard await look() else { return }\n        guard let snapshot else { return }\n        let changed = snapshot.setupID != lastSetup\n        lastSetup = snapshot.setupID\n        // A dock or undock (also one that happened while the Mac slept)\n        // brings the new setup's default back. A display merely waking up\n        // on the same setup leaves the user's windows alone.\n        if autoRestore, changed, reason == .displays || reason == .wake, let layout = defaultLayout(for: snapshot.setupID) {\n            _ = await restore(layout, automatic: true)\n        }\n    }\n\n    /// Reads the windows now; false (with `problem` set) when it can't.\n    @discardableResult\n    func look() async -> Bool {\n        do {\n            snapshot = try await Stackbar.windows()\n            problem = nil\n            return true\n        } catch {\n            problem = \"\\(error)\"\n            return false\n        }\n    }\n\n    // MARK: - Model\n\n    var setupID: String { snapshot?.setupID ?? lastSetup }\n\n    var here: [Layout] { layouts.filter { $0.setupID == setupID }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } }\n    var elsewhere: [Layout] { layouts.filter { $0.setupID != setupID }.sorted { ($0.setupName, $0.name) < ($1.setupName, $1.name) } }\n\n    func defaultLayout(for setup: String) -> Layout? {\n        defaults[setup].flatMap { id in layouts.first { $0.id == id } }\n    }\n\n    func isDefault(_ layout: Layout) -> Bool { defaults[layout.setupID] == layout.id }\n\n    /// \"Studio Display + Built-in\", main display first.\n    static func setupName(_ snapshot: WindowSnapshot) -> String {\n        let names = snapshot.displays.sorted { ($0.isMain ? 0 : 1, $0.frame.x) < ($1.isMain ? 0 : 1, $1.frame.x) }.map { display in\n            display.isBuiltIn ? \"Built-in\" : display.name\n        }\n        return names.isEmpty ? \"No displays\" : names.joined(separator: \" + \")\n    }\n\n    var currentSetupName: String { snapshot.map(Self.setupName) ?? \"This setup\" }\n\n    func find(_ name: String) -> Layout? {\n        let wanted = name.trimmingCharacters(in: .whitespaces)\n        guard !wanted.isEmpty else { return nil }\n        let match = { (layout: Layout) in layout.name.caseInsensitiveCompare(wanted) == .orderedSame }\n        let prefix = { (layout: Layout) in layout.name.lowercased().hasPrefix(wanted.lowercased()) }\n        return here.first(where: match) ?? elsewhere.first(where: match) ?? here.first(where: prefix) ?? elsewhere.first(where: prefix)\n    }\n\n    // MARK: - Actions\n\n    @discardableResult\n    func save(_ rawName: String) async -> CommandResult {\n        let name = String(rawName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40))\n        guard !name.isEmpty else { return .failure(\"Give the layout a name, like Desk\") }\n        guard await look(), let snapshot else { return .failure(problem ?? \"Can't read your windows\") }\n        let placements = Array(WindowPlacement.capture(snapshot).prefix(WindowPlacement.maxPerCall))\n        guard !placements.isEmpty else { return .failure(\"There are no windows to save\") }\n        let setupName = Self.setupName(snapshot)\n        var verb = \"Saved\"\n        if let index = layouts.firstIndex(where: { $0.setupID == snapshot.setupID && $0.name.caseInsensitiveCompare(name) == .orderedSame }) {\n            layouts[index].placements = placements\n            layouts[index].setupName = setupName\n            layouts[index].savedAt = Date()\n            verb = \"Updated\"\n        } else {\n            let id = String(UUID().uuidString.replacingOccurrences(of: \"-\", with: \"\").prefix(8)).lowercased()\n            layouts.append(Layout(id: id, name: name, setupID: snapshot.setupID, setupName: setupName, placements: placements, savedAt: Date()))\n            if defaults[snapshot.setupID] == nil { defaults[snapshot.setupID] = id }\n        }\n        let text = \"\\(verb) “\\(name)” with \\(Self.count(placements.count, \"window\")) on \\(setupName)\"\n        notice = text\n        return .message(text)\n    }\n\n    func restore(_ layout: Layout, automatic: Bool = false) async -> CommandResult {\n        do {\n            let result = try await Stackbar.arrangeWindows(layout.placements, launch: openApps)\n            _ = await look()\n            let text = Self.describe(result, layout: layout, automatic: automatic)\n            notice = text\n            return .message(text)\n        } catch {\n            let text = \"Couldn't restore “\\(layout.name)”: \\(error)\"\n            notice = text\n            return .failure(text)\n        }\n    }\n\n    func makeDefault(_ layout: Layout) {\n        defaults[layout.setupID] = layout.id\n        notice = \"“\\(layout.name)” now comes back by itself on \\(layout.setupName)\"\n    }\n\n    func delete(_ layout: Layout) {\n        layouts.removeAll { $0.id == layout.id }\n        if defaults[layout.setupID] == layout.id {\n            defaults[layout.setupID] = layouts.first { $0.setupID == layout.setupID }?.id\n        }\n        notice = \"Deleted “\\(layout.name)”\"\n    }\n\n    static func describe(_ result: ArrangeResult, layout: Layout, automatic: Bool) -> String {\n        var parts = [\"\\(automatic ? \"Brought back\" : \"Restored\") “\\(layout.name)”\"]\n        if result.moved > 0 {\n            parts[0] += \": moved \\(count(result.moved, \"window\"))\"\n        } else if result.opened.isEmpty && result.missing.isEmpty && result.failed.isEmpty {\n            parts[0] += \": everything was already in place\"\n        }\n        if !result.opened.isEmpty { parts.append(\"opened \\(list(result.opened))\") }\n        if !result.missing.isEmpty { parts.append(\"\\(count(result.missing.count, \"window\")) not open (\\(list(result.missing)))\") }\n        if !result.failed.isEmpty { parts.append(\"\\(list(result.failed)) wouldn't move\") }\n        return parts.joined(separator: \"; \")\n    }\n\n    static func count(_ value: Int, _ noun: String) -> String { \"\\(value) \\(noun)\\(value == 1 ? \"\" : \"s\")\" }\n\n    /// \"Slack, Mail and 2 more\", each name once.\n    static func list(_ names: [String]) -> String {\n        var unique: [String] = []\n        for name in names where !unique.contains(name) { unique.append(name) }\n        if unique.count > 3 { return unique.prefix(2).joined(separator: \", \") + \" and \\(unique.count - 2) more\" }\n        if unique.count > 1 { return unique.dropLast().joined(separator: \", \") + \" and \" + unique.last! }\n        return unique.first ?? \"\"\n    }\n\n    // MARK: - Surfaces\n\n    var menuBar: ViewContent {\n        Label(\"\", symbol: \"rectangle.3.group\").role(.secondary)\n    }\n\n    var panel: ViewContent {\n        let windows = snapshot?.windows.count ?? 0\n        PanelHeader(currentSetupName, subtitle: snapshot == nil ? \"Workspaces\" : \"\\(Self.count(windows, \"window\")) open · \\(Self.count(here.count, \"layout\")) for this setup\", symbol: \"rectangle.3.group\", theme: theme) {\n            IconButton(\"arrow.clockwise\", label: \"Look again\", theme: theme, key: \"reload\") { await self.look() }\n        }\n        if let problem {\n            Banner(problem, theme: .warning) {\n                ActionButton(\"Try Again\", theme: theme, key: \"retry\") { await self.look() }\n            }\n        }\n        if let notice {\n            Banner(notice, symbol: \"checkmark.circle.fill\", theme: theme) {\n                IconButton(\"xmark\", label: \"Dismiss\", theme: theme, key: \"dismiss\") { self.notice = nil }\n            }\n        }\n        TextField(\"Save the windows as… (Desk, Laptop, Focus)\", text: \"\", key: \"save\") { text in await self.save(text) }\n        if layouts.isEmpty {\n            EmptyState(\"No layouts yet\", symbol: \"macwindow.on.rectangle\", message: \"Arrange your windows the way you like them, type a name above and press Return. When this monitor setup comes back, so do your windows.\", theme: theme)\n        } else {\n            if here.isEmpty {\n                Card(\"This setup\", spacing: Space.xs, padding: Space.s) {\n                    Text(\"Nothing saved for \\(currentSetupName) yet. Save one above, or restore a layout from another setup below.\").role(.secondary).typography(.caption)\n                }\n            } else {\n                Card(\"This setup\", detail: autoRestore ? \"★ comes back by itself\" : nil, spacing: 0, padding: Space.s) {\n                    ForEach(here) { layout in self.row(layout) }\n                }\n            }\n            if !elsewhere.isEmpty {\n                Card(\"Other setups\", detail: \"\\(elsewhere.count)\", spacing: 0, padding: Space.s) {\n                    ForEach(elsewhere) { layout in self.row(layout, showSetup: true) }\n                }\n            }\n        }\n    }\n\n    @ViewBuilder private func row(_ layout: Layout, showSetup: Bool = false) -> ViewContent {\n        HStack(alignment: .center, spacing: Space.s) {\n            Button(key: \"default-\\(layout.id)\", action: { self.makeDefault(layout) }) {\n                Image(systemName: isDefault(layout) ? \"star.fill\" : \"star\")\n                    .font(size: 13)\n                    .foreground(isDefault(layout) ? theme.textPaint : .role(.secondary))\n                    .frame(width: 22, height: 22)\n            }\n            .accessibilityLabel(isDefault(layout) ? \"\\(layout.name) comes back by itself\" : \"Bring \\(layout.name) back by itself\")\n            Button(key: \"restore-\\(layout.id)\", action: { _ = await self.restore(layout) }) {\n                VStack(alignment: .start, spacing: 1) {\n                    Text(layout.name).typography(.body)\n                    Text(([Self.count(layout.windowCount, \"window\")] + (showSetup ? [layout.setupName] : []) + [\"saved \" + layout.savedAt.formatted(date: .abbreviated, time: .omitted)]).joined(separator: \" · \"))\n                        .role(.secondary).typography(.caption)\n                }\n                .frame(fillWidth: true, alignment: .leading)\n            }\n            .accessibilityLabel(\"Restore \\(layout.name)\")\n            IconButton(\"arrow.down.circle\", label: \"Save the windows now as \\(layout.name)\", theme: theme, key: \"update-\\(layout.id)\") {\n                if layout.setupID == self.setupID {\n                    await self.save(layout.name)\n                } else {\n                    self.notice = \"“\\(layout.name)” belongs to \\(layout.setupName); save a new layout for this setup instead\"\n                }\n            }\n            IconButton(\"trash\", label: \"Delete \\(layout.name)\", theme: theme, key: \"delete-\\(layout.id)\") { self.delete(layout) }\n        }\n        .padding(horizontal: Space.xs, vertical: 5)\n        .hover(scale: nil, highlight: .hoverWash, lift: false)\n    }\n\n    var settings: ViewContent {\n        Section(\"Restoring\") {\n            Toggle(\"Bring back the starred layout when displays change\", isOn: autoRestore) { self.autoRestore = $0 }\n            Toggle(\"Open apps that aren't running\", isOn: openApps) { self.openApps = $0 }\n            Text(\"A layout belongs to the displays it was saved on. When you plug in or unplug a display, or wake the Mac on a different set of displays, that set's starred layout comes back. Windows in full screen and on other Spaces stay where they are.\").role(.secondary)\n        }\n        Section(\"Layouts\") {\n            Text(\"\\(Self.count(layouts.count, \"layout\")) saved across \\(Self.count(Set(layouts.map(\\.setupID)).count, \"setup\")). Each one is a command you can give a hotkey in Stackbar's Settings.\").role(.secondary)\n            if !layouts.isEmpty {\n                Button(\"Delete every layout\", symbol: \"trash\", role: .critical) {\n                    self.layouts = []\n                    self.defaults = [:]\n                }\n            }\n        }\n        Section(\"Permission\") {\n            Text(\"Stackbar needs Accessibility access to see and move windows: System Settings › Privacy & Security › Accessibility.\").role(.secondary)\n        }\n    }\n\n    var commands: [Command] {\n        for layout in layouts {\n            Command(layout.commandID, title: \"Restore \\(layout.name)\", subtitle: \"\\(Self.count(layout.windowCount, \"window\")) · \\(layout.setupName)\", symbol: \"rectangle.3.group\", keywords: [\"workspace\", \"layout\", \"windows\", \"restore\", layout.name]) { _ in\n                await self.restore(layout)\n            }\n        }\n        Command(\"save\", title: \"Save Window Layout\", subtitle: \"Remember where every window is on these displays\", symbol: \"square.and.arrow.down\", keywords: [\"workspace\", \"layout\", \"windows\", \"save\", \"snapshot\"]) { invocation in\n            await self.save(invocation[\"name\"] ?? \"\")\n        }\n        .argument(\"name\", placeholder: \"Desk\")\n        Command(\"restore\", title: \"Restore Window Layout\", subtitle: \"Put windows back as a layout saved them\", symbol: \"rectangle.3.group\", keywords: [\"workspace\", \"layout\", \"windows\", \"restore\"]) { invocation in\n            await self.look()\n            guard let layout = self.find(invocation[\"layout\"] ?? \"\") ?? (invocation[\"layout\"] == nil ? self.defaultLayout(for: self.setupID) : nil) else {\n                return .failure(\"No layout called \\(invocation[\"layout\"] ?? \"that\")\")\n            }\n            return await self.restore(layout)\n        }\n        .argument(\"layout\", placeholder: \"Layout\", required: false, options: (here + elsewhere).prefix(20).map(\\.name))\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"layouts\", description: \"The user's saved window layouts, one per line, with their monitor setup, window count and whether they come back by themselves\") { _ in\n            await self.look()\n            guard !self.layouts.isEmpty else { return \"No layouts saved\" }\n            return (self.here + self.elsewhere).map { layout in\n                \"- \\(layout.name): \\(Self.count(layout.windowCount, \"window\")) on \\(layout.setupName)\\(layout.setupID == self.setupID ? \" (current setup)\" : \"\")\\(self.isDefault(layout) ? \", starred\" : \"\")\"\n            }.joined(separator: \"\\n\")\n        }\n        AgentTool(\"save_layout\", description: \"Saves where every window is now as a named layout for the current monitor setup; an existing name is updated\", parameters: [ToolParameter(\"name\", description: \"The layout's name, like Desk\")]) { arguments in\n            Self.text(await self.save(arguments.string(\"name\") ?? \"\"))\n        }\n        AgentTool(\"restore_layout\", description: \"Moves windows back to a saved layout, opening apps that aren't running if the user allows it\", parameters: [ToolParameter(\"name\", description: \"The layout's name\")]) { arguments in\n            await self.look()\n            guard let layout = self.find(arguments.string(\"name\") ?? \"\") else { return \"No layout called \\(arguments.string(\"name\") ?? \"that\")\" }\n            return Self.text(await self.restore(layout))\n        }\n    }\n\n    static func text(_ result: CommandResult) -> String {\n        switch result {\n        case .message(let text): return text ?? \"Done\"\n        case .failure(let text): return text\n        default: return \"Done\"\n        }\n    }\n}\n",
  "sourceDigest" : "b76cf1fe9ea78cd33acb1b76154689b1ca0701c1f6b2b0045bfd716a51596862"
}